Files
bunapi/src/routes/auth.ts
2025-12-17 17:09:16 -07:00

601 lines
15 KiB
TypeScript

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