diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90b2221 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# openssl rand -base64 32 +JWT_SECRET=development-secret-change-in-production + +JWT_EXPIRY=7d +JWT_REFRESH_EXPIRY=30d + +JWT_ISSUER=bunapi +JWT_AUDIENCE=bunapi-users + +COOKIE_SECURE=false +# COOKIE_DOMAIN=localhost + +NODE_ENV=development + +# Database +DB_PATH=data/app.db diff --git a/.gitignore b/.gitignore index 87e5610..8392bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,11 +25,18 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env.local .env.development.local .env.test.local .env.production.local +# database +/data/ +*.db +*.db-wal +*.db-shm + # vercel .vercel diff --git a/bun.lock b/bun.lock index ad7e91b..a1d70a8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "app", "dependencies": { + "@elysiajs/bearer": "^1.4.2", + "@elysiajs/jwt": "^1.4.0", "@elysiajs/openapi": "^1.4.11", "elysia": "latest", }, @@ -16,6 +18,10 @@ "packages": { "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], + "@elysiajs/bearer": ["@elysiajs/bearer@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.3" } }, "sha512-MK2aCFqnFMqMNSa1e/A6+Ow5uNl5LpKd8K4lCB2LIsyDrI6juxOUHAgqq+esgdSoh3urD1UIMqFC//TsqCQViA=="], + + "@elysiajs/jwt": ["@elysiajs/jwt@1.4.0", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Z0PvZhQxdDeKZ8HslXzDoXXD83NKExNPmoiAPki3nI2Xvh5wtUrBH+zWOD17yP14IbRo8fxGj3L25MRCAPsgPA=="], + "@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], @@ -42,6 +48,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/package.json b/package.json index 0dd525b..d3328ef 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "dev": "bun run --watch src/index.ts" }, "dependencies": { + "@elysiajs/bearer": "^1.4.2", + "@elysiajs/jwt": "^1.4.0", "@elysiajs/openapi": "^1.4.11", "elysia": "latest" }, diff --git a/seed.json b/seed.json new file mode 100644 index 0000000..e12773c --- /dev/null +++ b/seed.json @@ -0,0 +1,28 @@ +{ + "users": [ + { + "email": "admin@example.com", + "password": "adminpassword123", + "role": "admin" + }, + { + "email": "user@example.com", + "password": "userpassword123", + "role": "user" + } + ], + "apiTokens": [ + { + "name": "dev-token", + "token": "bun_dev_token_12345", + "userEmail": "admin@example.com", + "scopes": ["read", "write"] + }, + { + "name": "test-token", + "token": "bun_test_token_67890", + "userEmail": "user@example.com", + "scopes": ["read"] + } + ] +} diff --git a/src/config/auth.ts b/src/config/auth.ts new file mode 100644 index 0000000..49967dd --- /dev/null +++ b/src/config/auth.ts @@ -0,0 +1,27 @@ +// Auth configuration from environment variables + +if (!process.env.JWT_SECRET && process.env.NODE_ENV === "production") { + throw new Error("JWT_SECRET environment variable is required in production"); +} + +export const authConfig = { + jwt: { + secret: process.env.JWT_SECRET || "development-secret-change-in-production", + expiry: process.env.JWT_EXPIRY || "7d", + refreshExpiry: process.env.JWT_REFRESH_EXPIRY || "30d", + issuer: process.env.JWT_ISSUER || "bunapi", + audience: process.env.JWT_AUDIENCE || "bunapi-users", + }, + cookie: { + httpOnly: true, + secure: process.env.COOKIE_SECURE === "true" || process.env.NODE_ENV === "production", + sameSite: "strict" as const, + domain: process.env.COOKIE_DOMAIN, + path: "/", + maxAge: 7 * 24 * 60 * 60, + }, + password: { + saltRounds: 12, + minLength: 8, + }, +} as const; diff --git a/src/index.ts b/src/index.ts index d12f8db..3afa272 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,12 @@ import { Elysia } from "elysia"; import { openapi } from "@elysiajs/openapi"; import { health } from "./routes/health"; import { echo } from "./routes/echo"; +import { auth } from "./routes/auth"; +import "./services/db"; // Initialize database +import { initializeSeedData } from "./services/seed.service"; + +// Seed data from seed.json on startup +await initializeSeedData(); const app = new Elysia() .use( @@ -15,6 +21,8 @@ const app = new Elysia() tags: [ { name: "Health", description: "Health check endpoints" }, { name: "Echo", description: "Echo/ping endpoints" }, + { name: "Authentication", description: "User authentication with JWT" }, + { name: "API Tokens", description: "API token management for programmatic access" }, ], }, }) @@ -28,6 +36,7 @@ const app = new Elysia() }) .use(health) .use(echo) + .use(auth) .listen(3000); console.log( diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..d690ef8 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,600 @@ +import { Elysia, t } from "elysia"; +import { jwt } from "@elysiajs/jwt"; +import { bearer } from "@elysiajs/bearer"; + +import { authConfig } from "../config/auth"; +import type { JWTPayload, AuthResponse, ApiScope } from "../types/auth"; +import { + authenticateUser, + createUser, + findUserById, +} from "../services/user.service"; +import { + createApiToken, + validateApiToken, + listUserApiTokens, + revokeApiToken, + hasScope, +} from "../services/token.service"; + +// Request/Response schemas + +const loginSchema = t.Object({ + email: t.String({ format: "email", description: "User's email address" }), + password: t.String({ minLength: 8, description: "User's password (min 8 characters)" }), +}); + +const registerSchema = t.Object({ + email: t.String({ format: "email", description: "User's email address" }), + password: t.String({ minLength: 8, description: "Password (min 8 characters)" }), + name: t.Optional(t.String({ description: "User's display name" })), +}); + +const createTokenSchema = t.Object({ + name: t.String({ minLength: 1, maxLength: 100, description: "Token name for identification" }), + scopes: t.Optional( + t.Array(t.Union([t.Literal("read"), t.Literal("write"), t.Literal("delete")]), { + description: "Token permissions", + }) + ), + expiresInDays: t.Optional(t.Number({ minimum: 1, maximum: 365, description: "Token expiration in days" })), +}); + +const authResponseSchema = t.Object({ + success: t.Boolean(), + message: t.String(), + user: t.Optional( + t.Object({ + id: t.String(), + email: t.String(), + role: t.String(), + }) + ), + accessToken: t.Optional(t.String()), + refreshToken: t.Optional(t.String()), +}); + +const errorResponseSchema = t.Object({ + success: t.Boolean(), + error: t.String(), +}); + +export const auth = new Elysia({ prefix: "/auth" }) + .use( + jwt({ + name: "jwt", + secret: authConfig.jwt.secret, + exp: authConfig.jwt.expiry, + }) + ) + .use(bearer()) + + // Registration + .post( + "/register", + async ({ jwt, body, cookie: { accessToken, refreshToken } }): Promise => { + const result = await createUser(body); + + if ("error" in result && result.error) { + return { + success: false, + message: result.error, + }; + } + + const user = result.user!; + + // Generate tokens + const accessTokenValue = await jwt.sign({ + sub: user.id, + email: user.email, + role: user.role, + type: "access", + } satisfies JWTPayload); + + const refreshTokenValue = await jwt.sign({ + sub: user.id, + email: user.email, + role: user.role, + type: "refresh", + } satisfies JWTPayload); + + // Set cookies + accessToken.set({ + value: accessTokenValue, + ...authConfig.cookie, + }); + + refreshToken.set({ + value: refreshTokenValue, + ...authConfig.cookie, + maxAge: 30 * 24 * 60 * 60, // 30 days for refresh token + }); + + return { + success: true, + message: "Registration successful", + user: { + id: user.id, + email: user.email, + role: user.role, + }, + accessToken: accessTokenValue, + refreshToken: refreshTokenValue, + }; + }, + { + body: registerSchema, + response: { + 200: authResponseSchema, + }, + detail: { + summary: "Register", + description: "Create a new user account", + tags: ["Authentication"], + }, + } + ) + + // Login + .post( + "/login", + async ({ jwt, body, cookie: { accessToken, refreshToken } }): Promise => { + const result = await authenticateUser(body.email, body.password); + + if ("error" in result && result.error) { + return { + success: false, + message: result.error, + }; + } + + const user = result.user!; + + // Generate tokens + const accessTokenValue = await jwt.sign({ + sub: user.id, + email: user.email, + role: user.role, + type: "access", + } satisfies JWTPayload); + + const refreshTokenValue = await jwt.sign({ + sub: user.id, + email: user.email, + role: user.role, + type: "refresh", + } satisfies JWTPayload); + + // Set cookies + accessToken.set({ + value: accessTokenValue, + ...authConfig.cookie, + }); + + refreshToken.set({ + value: refreshTokenValue, + ...authConfig.cookie, + maxAge: 30 * 24 * 60 * 60, // 30 days for refresh token + }); + + return { + success: true, + message: "Login successful", + user: { + id: user.id, + email: user.email, + role: user.role, + }, + accessToken: accessTokenValue, + refreshToken: refreshTokenValue, + }; + }, + { + body: loginSchema, + response: { + 200: authResponseSchema, + }, + detail: { + summary: "Login", + description: "Authenticate with email and password to receive access and refresh tokens", + tags: ["Authentication"], + }, + } + ) + + // Logout + .post( + "/logout", + ({ cookie: { accessToken, refreshToken } }) => { + accessToken.remove(); + refreshToken.remove(); + + return { + success: true, + message: "Logged out successfully", + }; + }, + { + detail: { + summary: "Logout", + description: "Clear authentication cookies", + tags: ["Authentication"], + }, + } + ) + + // Refresh token + .post( + "/refresh", + async ({ jwt, cookie: { accessToken, refreshToken }, set }) => { + const token = refreshToken.value; + + if (!token || typeof token !== "string") { + set.status = 401; + return { + success: false, + error: "Refresh token required", + }; + } + + const payload = (await jwt.verify(token)) as JWTPayload | false; + + if (!payload || payload.type !== "refresh") { + set.status = 401; + return { + success: false, + error: "Invalid refresh token", + }; + } + + const user = await findUserById(payload.sub); + if (!user) { + set.status = 401; + return { + success: false, + error: "User not found", + }; + } + + const newAccessToken = await jwt.sign({ + sub: user.id, + email: user.email, + role: user.role, + type: "access", + } satisfies JWTPayload); + + accessToken.set({ + value: newAccessToken, + ...authConfig.cookie, + }); + + return { + success: true, + message: "Token refreshed", + accessToken: newAccessToken, + }; + }, + { + detail: { + summary: "Refresh Token", + description: "Exchange a valid refresh token for a new access token", + tags: ["Authentication"], + }, + } + ) + + // Get current user + .get( + "/me", + async ({ jwt, cookie: { accessToken }, bearer, set }) => { + const cookieToken = typeof accessToken.value === "string" ? accessToken.value : null; + const bearerToken = bearer && bearer.length > 0 ? bearer : null; + const token = cookieToken || bearerToken; + + if (!token) { + set.status = 401; + return { + success: false, + error: "Authentication required", + }; + } + + const payload = (await jwt.verify(token)) as JWTPayload | false; + + if (!payload) { + set.status = 401; + return { + success: false, + error: "Invalid or expired token", + }; + } + + const user = await findUserById(payload.sub); + + if (!user) { + set.status = 401; + return { + success: false, + error: "User not found", + }; + } + + return { + success: true, + user: { + id: user.id, + email: user.email, + role: user.role, + }, + }; + }, + { + detail: { + summary: "Get Current User", + description: "Get authenticated user profile", + tags: ["Authentication"], + }, + } + ) + + // Create API token (requires JWT auth - API tokens cannot create other tokens) + .post( + "/tokens", + async ({ jwt, cookie: { accessToken }, bearer, body, set }) => { + const cookieToken = typeof accessToken.value === "string" && accessToken.value.length > 0 ? accessToken.value : null; + const bearerToken = typeof bearer === "string" && bearer.length > 0 ? bearer : null; + const token = cookieToken || bearerToken; + + if (!token) { + set.status = 401; + return { + success: false, + error: "Authentication required", + }; + } + + const payload = (await jwt.verify(token)) as JWTPayload | false; + + if (!payload) { + set.status = 401; + return { + success: false, + error: "Invalid or expired token", + }; + } + + // Verify user still exists + const user = await findUserById(payload.sub); + if (!user) { + set.status = 401; + return { + success: false, + error: "User not found", + }; + } + + const result = await createApiToken( + user.id, + body.name, + (body.scopes as ApiScope[]) || ["read"], + body.expiresInDays + ); + + return { + success: true, + message: "API token created - save it now, it won't be shown again", + token: result.plainToken, + tokenId: result.token.id, + name: result.token.name, + scopes: result.token.scopes, + expiresAt: result.token.expiresAt, + }; + }, + { + body: createTokenSchema, + detail: { + summary: "Create API Token", + description: "Generate a new API token", + tags: ["API Tokens"], + }, + } + ) + + // List API tokens (requires JWT auth) + .get( + "/tokens", + async ({ jwt, cookie: { accessToken }, bearer, set }) => { + const cookieToken = typeof accessToken.value === "string" && accessToken.value.length > 0 ? accessToken.value : null; + const bearerToken = typeof bearer === "string" && bearer.length > 0 ? bearer : null; + const token = cookieToken || bearerToken; + + if (!token) { + set.status = 401; + return { + success: false, + error: "Authentication required", + }; + } + + const payload = (await jwt.verify(token)) as JWTPayload | false; + + if (!payload) { + set.status = 401; + return { + success: false, + error: "Invalid or expired token", + }; + } + + const user = await findUserById(payload.sub); + if (!user) { + set.status = 401; + return { + success: false, + error: "User not found", + }; + } + + const tokens = await listUserApiTokens(user.id); + + return { + success: true, + tokens, + }; + }, + { + detail: { + summary: "List API Tokens", + description: "List all API tokens for the user", + tags: ["API Tokens"], + }, + } + ) + + // Revoke API token (requires JWT auth) + .delete( + "/tokens/:id", + async ({ jwt, cookie: { accessToken }, bearer, params, set }) => { + const cookieToken = typeof accessToken.value === "string" && accessToken.value.length > 0 ? accessToken.value : null; + const bearerToken = typeof bearer === "string" && bearer.length > 0 ? bearer : null; + const token = cookieToken || bearerToken; + + if (!token) { + set.status = 401; + return { + success: false, + error: "Authentication required", + }; + } + + const payload = (await jwt.verify(token)) as JWTPayload | false; + + if (!payload) { + set.status = 401; + return { + success: false, + error: "Invalid or expired token", + }; + } + + const user = await findUserById(payload.sub); + if (!user) { + set.status = 401; + return { + success: false, + error: "User not found", + }; + } + + const revoked = await revokeApiToken(params.id, user.id); + + if (!revoked) { + set.status = 404; + return { + success: false, + error: "Token not found", + }; + } + + return { + success: true, + message: "Token revoked successfully", + }; + }, + { + params: t.Object({ + id: t.String({ description: "Token ID to revoke" }), + }), + detail: { + summary: "Revoke API Token", + description: "Revoke an API token", + tags: ["API Tokens"], + }, + } + ) + + // Verify API token + .get( + "/api/verify", + async ({ bearer, set }) => { + if (!bearer) { + set.status = 400; + set.headers["WWW-Authenticate"] = `Bearer realm='api', error="invalid_request"`; + return { + success: false, + error: "Bearer token required", + }; + } + + const validation = await validateApiToken(bearer); + + if (!validation.valid || !validation.token) { + set.status = 401; + set.headers["WWW-Authenticate"] = `Bearer realm='api', error="invalid_token"`; + return { + success: false, + error: validation.error || "Invalid token", + }; + } + + return { + success: true, + message: "API token is valid", + tokenId: validation.token.id, + scopes: validation.token.scopes, + expiresAt: validation.token.expiresAt, + }; + }, + { + detail: { + summary: "Verify API Token", + description: "Validate an API token and return its metadata", + tags: ["API Tokens"], + }, + } + ); + +// Helper to validate JWT from request context +export async function validateJWT( + jwt: { verify: (token: string) => Promise }, + accessTokenCookie: { value: unknown }, + bearer: string | undefined +): Promise<{ user: JWTPayload; error?: never } | { user?: never; error: string }> { + const token = + (typeof accessTokenCookie.value === "string" && accessTokenCookie.value) || bearer; + + if (!token) { + return { error: "Authentication required" }; + } + + const payload = await jwt.verify(token); + + if (!payload) { + return { error: "Invalid or expired token" }; + } + + return { user: payload as JWTPayload }; +} + +// Helper to validate API token with scope check +export async function validateApiTokenWithScope( + bearerToken: string | undefined, + requiredScope: ApiScope +): Promise<{ valid: true; userId: string } | { valid: false; error: string }> { + if (!bearerToken) { + return { valid: false, error: "Bearer token required" }; + } + + const validation = await validateApiToken(bearerToken); + + if (!validation.valid || !validation.token) { + return { valid: false, error: validation.error || "Invalid token" }; + } + + if (!hasScope(validation.token, requiredScope)) { + return { valid: false, error: `Token missing required scope: ${requiredScope}` }; + } + + return { valid: true, userId: validation.token.userId }; +} diff --git a/src/services/db.ts b/src/services/db.ts new file mode 100644 index 0000000..65e5739 --- /dev/null +++ b/src/services/db.ts @@ -0,0 +1,42 @@ +import { Database } from "bun:sqlite"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; + +const DB_PATH = process.env.DB_PATH || "data/app.db"; + +try { + mkdirSync(dirname(DB_PATH), { recursive: true }); +} catch {} + +export const db = new Database(DB_PATH); +db.exec("PRAGMA journal_mode = WAL;"); + +// Initialize tables +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); +`); + +console.log(`[DB] SQLite initialized at ${DB_PATH}`); diff --git a/src/services/seed.service.ts b/src/services/seed.service.ts new file mode 100644 index 0000000..ac9ff8d --- /dev/null +++ b/src/services/seed.service.ts @@ -0,0 +1,106 @@ +// Seed service - loads initial data from seed.json for development + +import { db } from "./db"; +import type { UserRole, ApiScope } from "../types/auth"; + +interface SeedUser { + email: string; + password: string; + role?: UserRole; +} + +interface SeedApiToken { + name: string; + token: string; + userEmail: string; + scopes?: ApiScope[]; +} + +interface SeedData { + users?: SeedUser[]; + apiTokens?: SeedApiToken[]; +} + +async function hashApiToken(token: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(token); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function initializeSeedData(): Promise { + const seedFile = Bun.file("seed.json"); + + if (!(await seedFile.exists())) { + console.log("[Seed] No seed.json found, skipping"); + return; + } + + console.log("[Seed] Initializing..."); + + const seedData: SeedData = await seedFile.json(); + const userIdMap = new Map(); + + // Seed users + if (seedData.users) { + const insertUser = db.prepare(` + INSERT OR IGNORE INTO users (id, email, password_hash, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + + for (const user of seedData.users) { + const email = user.email.toLowerCase().trim(); + + // Check if user already exists + const existing = db.query("SELECT id FROM users WHERE email = ?").get(email) as { id: string } | null; + if (existing) { + userIdMap.set(email, existing.id); + continue; + } + + const passwordHash = await Bun.password.hash(user.password, { + algorithm: "argon2id", + memoryCost: 65536, + timeCost: 2, + }); + + const now = new Date().toISOString(); + const userId = crypto.randomUUID(); + + insertUser.run(userId, email, passwordHash, user.role || "user", now, now); + userIdMap.set(email, userId); + console.log(`[Seed] Created user: ${email} (${user.role || "user"})`); + } + } + + // Seed API tokens + if (seedData.apiTokens) { + const insertToken = db.prepare(` + INSERT OR IGNORE INTO api_tokens (id, name, token_hash, user_id, scopes, expires_at, last_used_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const token of seedData.apiTokens) { + const userId = userIdMap.get(token.userEmail.toLowerCase()); + if (!userId) { + console.warn(`[Seed] Skipping token "${token.name}": user ${token.userEmail} not found`); + continue; + } + + const tokenHash = await hashApiToken(token.token); + + // Check if token already exists + const existing = db.query("SELECT id FROM api_tokens WHERE token_hash = ?").get(tokenHash); + if (existing) continue; + + const scopes = JSON.stringify(token.scopes || ["read"]); + const now = new Date().toISOString(); + + insertToken.run(crypto.randomUUID(), token.name, tokenHash, userId, scopes, null, null, now); + console.log(`[Seed] Created API token: ${token.name} (scopes: ${token.scopes?.join(", ") || "read"})`); + } + } + + console.log("[Seed] Complete"); +} diff --git a/src/services/token.service.ts b/src/services/token.service.ts new file mode 100644 index 0000000..ed8b388 --- /dev/null +++ b/src/services/token.service.ts @@ -0,0 +1,124 @@ +// API token service + +import { db } from "./db"; +import type { ApiToken, ApiScope, ApiTokenValidation } from "../types/auth"; + +interface TokenRow { + id: string; + name: string; + token_hash: string; + user_id: string; + scopes: string; + expires_at: string | null; + last_used_at: string | null; + created_at: string; +} + +function rowToToken(row: TokenRow): ApiToken { + return { + id: row.id, + name: row.name, + token: row.token_hash, + userId: row.user_id, + scopes: JSON.parse(row.scopes) as ApiScope[], + expiresAt: row.expires_at ? new Date(row.expires_at) : null, + lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : null, + createdAt: new Date(row.created_at), + }; +} + +export function generateApiToken(prefix: string = "bun"): string { + const randomBytes = crypto.randomUUID().replace(/-/g, ""); + return `${prefix}_${randomBytes}`; +} + +export async function hashApiToken(token: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(token); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function createApiToken( + userId: string, + name: string, + scopes: ApiScope[] = ["read"], + expiresInDays?: number +): Promise<{ token: ApiToken; plainToken: string }> { + const plainToken = generateApiToken(); + const hashedToken = await hashApiToken(plainToken); + const now = new Date().toISOString(); + const expiresAt = expiresInDays + ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString() + : null; + + const tokenId = crypto.randomUUID(); + + db.prepare(` + INSERT INTO api_tokens (id, name, token_hash, user_id, scopes, expires_at, last_used_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(tokenId, name, hashedToken, userId, JSON.stringify(scopes), expiresAt, null, now); + + const token: ApiToken = { + id: tokenId, + name, + token: hashedToken, + userId, + scopes, + expiresAt: expiresAt ? new Date(expiresAt) : null, + lastUsedAt: null, + createdAt: new Date(now), + }; + + return { token, plainToken }; +} + +export async function validateApiToken( + plainToken: string +): Promise { + if (!plainToken) { + return { valid: false, error: "Token is required" }; + } + + const hashedToken = await hashApiToken(plainToken); + const row = db.query("SELECT * FROM api_tokens WHERE token_hash = ?").get(hashedToken) as TokenRow | null; + + if (!row) { + return { valid: false, error: "Invalid token" }; + } + + const token = rowToToken(row); + + if (token.expiresAt && token.expiresAt < new Date()) { + return { valid: false, error: "Token has expired" }; + } + + // Update last_used_at + db.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?") + .run(new Date().toISOString(), token.id); + + return { valid: true, token }; +} + +export function hasScope(token: ApiToken, scope: ApiScope): boolean { + return token.scopes.includes(scope) || token.scopes.includes("admin"); +} + +export async function revokeApiToken(tokenId: string, userId: string): Promise { + const result = db.prepare("DELETE FROM api_tokens WHERE id = ? AND user_id = ?") + .run(tokenId, userId); + return result.changes > 0; +} + +export async function listUserApiTokens( + userId: string +): Promise[]> { + const rows = db.query("SELECT * FROM api_tokens WHERE user_id = ?").all(userId) as TokenRow[]; + + return rows.map((row) => { + const token = rowToToken(row); + const { token: _, ...tokenWithoutHash } = token; + return tokenWithoutHash; + }); +} diff --git a/src/services/user.service.ts b/src/services/user.service.ts new file mode 100644 index 0000000..7f13aa2 --- /dev/null +++ b/src/services/user.service.ts @@ -0,0 +1,114 @@ +// User service + +import { db } from "./db"; +import type { AuthUser, UserRole, RegisterRequest } from "../types/auth"; + +interface UserRow { + id: string; + email: string; + password_hash: string; + role: string; + created_at: string; + updated_at: string; +} + +function rowToUser(row: UserRow): AuthUser & { passwordHash: string } { + return { + id: row.id, + email: row.email, + passwordHash: row.password_hash, + role: row.role as UserRole, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; +} + +export async function hashPassword(password: string): Promise { + return await Bun.password.hash(password, { + algorithm: "argon2id", + memoryCost: 65536, + timeCost: 2, + }); +} + +export async function verifyPassword( + password: string, + hash: string +): Promise { + return await Bun.password.verify(password, hash); +} + +export async function findUserByEmail( + email: string +): Promise<(AuthUser & { passwordHash: string }) | null> { + const row = db.query("SELECT * FROM users WHERE email = ?").get(email.toLowerCase()) as UserRow | null; + return row ? rowToUser(row) : null; +} + +export async function findUserById(id: string): Promise { + const row = db.query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null; + if (!row) return null; + + const user = rowToUser(row); + const { passwordHash, ...userWithoutPassword } = user; + return userWithoutPassword; +} + +export async function createUser( + data: RegisterRequest +): Promise<{ user: AuthUser; error?: never } | { user?: never; error: string }> { + const email = data.email.toLowerCase().trim(); + + const existing = db.query("SELECT id FROM users WHERE email = ?").get(email); + if (existing) { + return { error: "User with this email already exists" }; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return { error: "Invalid email format" }; + } + + if (data.password.length < 8) { + return { error: "Password must be at least 8 characters long" }; + } + + const passwordHash = await hashPassword(data.password); + const now = new Date().toISOString(); + const userId = crypto.randomUUID(); + + db.prepare(` + INSERT INTO users (id, email, password_hash, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(userId, email, passwordHash, "user", now, now); + + return { + user: { + id: userId, + email, + role: "user" as UserRole, + createdAt: new Date(now), + updatedAt: new Date(now), + }, + }; +} + +export async function authenticateUser( + email: string, + password: string +): Promise<{ user: AuthUser; error?: never } | { user?: never; error: string }> { + const user = await findUserByEmail(email); + + if (!user) { + return { error: "Invalid email or password" }; + } + + const isValidPassword = await verifyPassword(password, user.passwordHash); + + if (!isValidPassword) { + return { error: "Invalid email or password" }; + } + + const { passwordHash, ...userWithoutPassword } = user; + return { user: userWithoutPassword }; +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..27a3b53 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,59 @@ +// Authentication types + +export interface JWTPayload { + sub: string; + email: string; + role: UserRole; + type: "access" | "refresh"; + iat?: number; + exp?: number; +} + +export type UserRole = "user" | "admin" | "moderator"; + +export interface AuthUser { + id: string; + email: string; + role: UserRole; + createdAt: Date; + updatedAt: Date; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface RegisterRequest { + email: string; + password: string; + name?: string; +} + +export interface AuthResponse { + success: boolean; + message: string; + user?: Omit; + accessToken?: string; + refreshToken?: string; +} + +export interface ApiToken { + id: string; + name: string; + token: string; + userId: string; + scopes: ApiScope[]; + expiresAt: Date | null; + lastUsedAt: Date | null; + createdAt: Date; +} + +export type ApiScope = "read" | "write" | "delete" | "admin"; + + +export interface ApiTokenValidation { + valid: boolean; + token?: ApiToken; + error?: string; +}