Added auth
This commit is contained in:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user