Added auth
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal 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
7
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
8
bun.lock
8
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=="],
|
||||
|
||||
@@ -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
28
seed.json
Normal 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
27
src/config/auth.ts
Normal 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;
|
||||
@@ -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
600
src/routes/auth.ts
Normal 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
42
src/services/db.ts
Normal 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}`);
|
||||
106
src/services/seed.service.ts
Normal file
106
src/services/seed.service.ts
Normal 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");
|
||||
}
|
||||
124
src/services/token.service.ts
Normal file
124
src/services/token.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
114
src/services/user.service.ts
Normal file
114
src/services/user.service.ts
Normal 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
59
src/types/auth.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user