601 lines
15 KiB
TypeScript
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 };
|
|
}
|