Added auth

This commit is contained in:
2025-12-17 17:09:16 -07:00
parent 8c8252c83e
commit cc97015cf4
13 changed files with 1142 additions and 0 deletions

16
.env.example Normal file
View File

@@ -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

7
.gitignore vendored
View File

@@ -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

View File

@@ -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=="],

View File

@@ -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"
},

28
seed.json Normal file
View File

@@ -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"]
}
]
}

27
src/config/auth.ts Normal file
View File

@@ -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;

View File

@@ -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(

600
src/routes/auth.ts Normal file
View File

@@ -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<AuthResponse> => {
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<AuthResponse> => {
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<JWTPayload | false> },
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 };
}

42
src/services/db.ts Normal file
View File

@@ -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}`);

View File

@@ -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<string> {
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<void> {
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<string, string>();
// 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");
}

View File

@@ -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<string> {
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<ApiTokenValidation> {
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<boolean> {
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<Omit<ApiToken, "token">[]> {
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;
});
}

View File

@@ -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<string> {
return await Bun.password.hash(password, {
algorithm: "argon2id",
memoryCost: 65536,
timeCost: 2,
});
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
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<AuthUser | null> {
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 };
}

59
src/types/auth.ts Normal file
View File

@@ -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<AuthUser, "createdAt" | "updatedAt">;
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;
}