From 73a6b01ed7d333827e85dcf1bcc8d1da621b260d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 18 Apr 2026 23:24:24 -0300 Subject: [PATCH 01/11] feat(modules/auth): semi-intergation of auth module --- modules/auth/controllers/index.controller.ts | 24 ++++++++++++++++++++ modules/auth/schemas/signin.schema.ts | 9 ++++++++ 2 files changed, 33 insertions(+) create mode 100644 modules/auth/schemas/signin.schema.ts diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index b94a616..fac91cf 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -5,12 +5,14 @@ import jwt from "jsonwebtoken"; import { ZodError } from "zod"; import jwtConfig from "@/config/jwt"; +import prisma from "@/database/prisma/client"; import redisClient from "@/database/redis/client"; import NotFoundError from "../exceptions/notfound.exception"; import UnauthorizedError from "../exceptions/unauthorized.exception"; import CredentialSchema from "../schemas/credential.schema"; import RefreshTokenSchema from "../schemas/refresh-token.schema"; +import SignInSchema from "../schemas/signin.schema"; import type { JwtSubject } from "../types/jwt"; export default { @@ -87,6 +89,28 @@ export default { } }, + async signin(req: FastifyRequest, reply: FastifyReply) { + try { + const { password, ...credentials } = SignInSchema.parse(req.body); + + const userCounter = await prisma.user.count({ + where: { + email: credentials.email, + }, + }); + + if (userCounter > 0) { + throw new UnauthorizedError(req.t("Email already in use")); + } + } catch (e) { + if (e instanceof UnauthorizedError) { + reply.status(401).send({ error: e.message }); + } else { + reply.status(500).send({ error: req.t("Server error") }); + } + } + }, + logout(req: FastifyRequest, reply: FastifyReply) { try { const bearerToken = req.headers.authorization; diff --git a/modules/auth/schemas/signin.schema.ts b/modules/auth/schemas/signin.schema.ts new file mode 100644 index 0000000..495a413 --- /dev/null +++ b/modules/auth/schemas/signin.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +const SignInSchema = z.object({ + email: z.email(), + username: z.string().min(3).max(30), + password: z.string().min(8), +}); + +export default SignInSchema; From 48baa6401786ee6822a22e3a1ef6303ec2e4790e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Fri, 1 May 2026 00:41:29 -0300 Subject: [PATCH 02/11] feat: signup + login fixed --- .env.example | 18 +- @types/i18next.d.ts | 3 + bun.lock | 10 + ci/Dockerfile.dev | 16 ++ compose.yml | 58 ++++ config/i18n.ts | 6 +- database/prisma/schema.prisma | 10 +- index.ts | 36 +++ locales/en-US/zod.json | 3 + locales/es-ES/errors.json | 3 +- locales/es-ES/zod.json | 3 + locales/fr-FR/zod.json | 1 + locales/pt-BR/zod.json | 1 + modules/auth/controllers/index.controller.ts | 269 ++++++++++-------- modules/auth/exceptions/conflict.exception.ts | 8 + modules/auth/exceptions/index.exception.ts | 3 + modules/auth/exceptions/notfound.exception.ts | 4 +- .../auth/exceptions/unauthorized.exception.ts | 4 +- modules/auth/routes/index.router.ts | 8 +- modules/auth/schemas/credential.schema.ts | 4 +- modules/auth/schemas/index.schema.ts | 4 + modules/auth/schemas/refresh-token.schema.ts | 4 +- modules/auth/schemas/signin.schema.ts | 4 +- modules/auth/schemas/signup.schema.ts | 18 ++ modules/auth/serivces/jwt.service.ts | 39 +++ modules/users/controllers/index.controller.ts | 20 ++ package.json | 2 + plugins/i18n.ts | 4 + plugins/jwt.ts | 62 ++++ plugins/zod.ts | 7 + 30 files changed, 478 insertions(+), 154 deletions(-) create mode 100644 @types/i18next.d.ts create mode 100644 ci/Dockerfile.dev create mode 100644 compose.yml create mode 100644 locales/en-US/zod.json create mode 100644 locales/es-ES/zod.json create mode 100644 locales/fr-FR/zod.json create mode 100644 locales/pt-BR/zod.json create mode 100644 modules/auth/exceptions/conflict.exception.ts create mode 100644 modules/auth/exceptions/index.exception.ts create mode 100644 modules/auth/schemas/index.schema.ts create mode 100644 modules/auth/schemas/signup.schema.ts create mode 100644 modules/auth/serivces/jwt.service.ts create mode 100644 plugins/jwt.ts create mode 100644 plugins/zod.ts diff --git a/.env.example b/.env.example index a30630f..9bb2dca 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,12 @@ -NODE_ENV=development +NODE_ENV= -DATABASE_URL=${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_SCHEMA} +POSTGRES_USERNAME= +POSTGRES_DATABASE= +POSTGRES_PASSWORD= +POSTGRES_HOST= +POSTGRES_PORT= -DB_DRIVER= -DB_USER= -DB_SCHEMA= -DB_PASSWORD= -DB_HOST= -DB_PORT= +REDIS_HOST= +REDIS_PORT= +REDIS_USERNAME= +REDIS_PASSWORD= diff --git a/@types/i18next.d.ts b/@types/i18next.d.ts new file mode 100644 index 0000000..7ccbc6e --- /dev/null +++ b/@types/i18next.d.ts @@ -0,0 +1,3 @@ +declare namespace i18next { + export type TFunction = (key: string, options?: unknown) => string; +} diff --git a/bun.lock b/bun.lock index 8c6848d..48432a2 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "bcrypt": "^6.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", "i18next": "^26.0.3", @@ -21,6 +22,7 @@ "@biomejs/biome": "2.4.10", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", + "@types/bcrypt": "^6.0.0", "@types/bun": "^1.3.11", "@types/jsonwebtoken": "^9.0.10", "@typescript/native-preview": "^7.0.0-dev.20260403.1", @@ -161,6 +163,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], @@ -209,6 +213,8 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], + "better-result": ["better-result@2.8.2", "", {}, "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -437,6 +443,10 @@ "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], diff --git a/ci/Dockerfile.dev b/ci/Dockerfile.dev new file mode 100644 index 0000000..d6b6795 --- /dev/null +++ b/ci/Dockerfile.dev @@ -0,0 +1,16 @@ +FROM oven/bun:1 +WORKDIR /usr/src/app + +COPY package.json bun.lock ./ +ENV CI=true +RUN bun install + +COPY . . +RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" \ + bunx --bun prisma generate + +ENV NODE_ENV=development + +USER bun +EXPOSE 8080/tcp +CMD [ "bun", "dev" ] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..6a67162 --- /dev/null +++ b/compose.yml @@ -0,0 +1,58 @@ +name: dojoh-api + +services: + api: + container_name: dojoh-api + image: dojoh-api:latest + build: + dockerfile: ci/Dockerfile.dev + context: . + ports: + - "8080:8080" + environment: + - NODE_ENV=development + networks: + - dojoh-net + volumes: + - ./:/usr/src/app + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + depends_on: + - pg + - redis + + pg: + container_name: dojoh-pg + image: postgres:18 + environment: + - POSTGRES_USER=dojoh + - POSTGRES_PASSWORD=my_secret_password + - POSTGRES_DB=dojoh_dev + ports: + - "5432:5432" + networks: + - dojoh-net + volumes: + - pg_data:/var/lib/postgresql/18/docker + + redis: + container_name: dojoh-redis + image: redis:7 + ports: + - "6379:6379" + environment: + - REDIS_PASSWORD=my_secret_password + command: > + sh -c "redis-server --requirepass ${REDIS_PASSWORD}" + networks: + - dojoh-net + +networks: + dojoh-net: + driver: bridge + +volumes: + pg_data: {} diff --git a/config/i18n.ts b/config/i18n.ts index f37c9fc..ccd6a39 100644 --- a/config/i18n.ts +++ b/config/i18n.ts @@ -1,9 +1,11 @@ import path from "node:path"; +import type * as i18next from "i18next"; + export default { fallbackLng: "en-US", supportedLngs: ["en-US", "es-ES", "fr-FR", "pt-BR"], - ns: ["messages", "errors"], + ns: ["messages", "errors", "zod"], defaultNS: "messages", backend: { loadPath: path.join(process.cwd(), "locales/{{lng}}/{{ns}}.json"), @@ -11,4 +13,4 @@ export default { interpolation: { escapeValue: false, }, -}; +} satisfies i18next.InitOptions; diff --git a/database/prisma/schema.prisma b/database/prisma/schema.prisma index a76fc18..29b918a 100644 --- a/database/prisma/schema.prisma +++ b/database/prisma/schema.prisma @@ -43,11 +43,11 @@ model ImageVariant { } model User { - id Int @id @default(autoincrement()) - email String @unique - nickname String @unique - password String? - avatar Image? @relation(fields: [avatar_id], references: [id]) + id Int @id @default(autoincrement()) + email String @unique + nickname String @unique + password String + avatar Image? @relation(fields: [avatar_id], references: [id]) avatar_id Int? created_at DateTime @default(now()) diff --git a/index.ts b/index.ts index ae89b62..10d797c 100644 --- a/index.ts +++ b/index.ts @@ -2,12 +2,14 @@ import Fastify from "fastify"; import env from "./config/env"; import i18nPlugin from "./plugins/i18n"; +import jwtPlugin from "./plugins/jwt"; const f = Fastify({ logger: true, }); f.register(i18nPlugin); +f.register(jwtPlugin); f.get("/", async () => { return { message: "Hello World!" }; @@ -18,6 +20,40 @@ f.get("/health", async () => { }); f.post("/echo", async (request) => { + console.debug("[BODY]: ", request.body); + console.debug("[QUERY]: ", request.query); + console.debug("[PARAMS]: ", request.params); + console.debug("[HEADERS]: ", request.headers); + // console.debug("[RAW]: ", request.raw); + // console.debug("[SERVER]: ", request.server); + console.debug("[ID]: ", request.id); + console.debug("[IP]: ", request.ip); + console.debug("[IPS]: ", request.ips); + console.debug("[HOST]: ", request.host); + console.debug("[HOSTNAME]: ", request.hostname); + console.debug("[PORT]: ", request.port); + console.debug("[PROTOCOL]: ", request.protocol); + console.debug("[URL]: ", request.url); + console.debug("[ROUTE_METHOD]: ", request.routeOptions.method); + console.debug( + "[ROUTE_BODY_LIMIT]: ", + request.routeOptions.bodyLimit + ? `${(request.routeOptions.bodyLimit / (1024 * 1024)).toFixed(2)} MB` + : "N/A", + ); + console.debug("[ROUTE_URL]: ", request.routeOptions.url); + console.debug( + "[ROUTE_ATTACH_VALIDATION]: ", + request.routeOptions.attachValidation, + ); + console.debug("[ROUTE_LOG_LEVEL]: ", request.routeOptions.logLevel); + console.debug("[ROUTE_VERSION]: ", request.routeOptions.version); + console.debug("[ROUTE_EXPOSE_HEAD]: ", request.routeOptions.exposeHeadRoute); + console.debug( + "[ROUTE_PREFIX_TRAILING_SLASH]: ", + request.routeOptions.prefixTrailingSlash, + ); + return { received: request.body }; }); diff --git a/locales/en-US/zod.json b/locales/en-US/zod.json new file mode 100644 index 0000000..ad246a9 --- /dev/null +++ b/locales/en-US/zod.json @@ -0,0 +1,3 @@ +{ + "nickname_rules": "The nickname must start and end with an alphanumeric character, and can contain dots, underscores, or hyphens in between. Consecutive dots, underscores, or hyphens are not allowed." +} diff --git a/locales/es-ES/errors.json b/locales/es-ES/errors.json index 3ea337d..124ce7d 100644 --- a/locales/es-ES/errors.json +++ b/locales/es-ES/errors.json @@ -1,4 +1,5 @@ { "Invalid credentials": "Credenciales inválidas", - "Server error": "Error del servidor" + "Server error": "Error del servidor", + "Email already in use": "El correo electrónico ya está en uso" } diff --git a/locales/es-ES/zod.json b/locales/es-ES/zod.json new file mode 100644 index 0000000..bee87a1 --- /dev/null +++ b/locales/es-ES/zod.json @@ -0,0 +1,3 @@ +{ + "nickname_rules": "El apodo debe comenzar y terminar con un carácter alfanumérico, y puede contener puntos, guiones bajos o guiones en el medio. No se permiten puntos, guiones bajos o guiones consecutivos." +} diff --git a/locales/fr-FR/zod.json b/locales/fr-FR/zod.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/locales/fr-FR/zod.json @@ -0,0 +1 @@ +{} diff --git a/locales/pt-BR/zod.json b/locales/pt-BR/zod.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/locales/pt-BR/zod.json @@ -0,0 +1 @@ +{} diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index fac91cf..0a2d16f 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import bcrypt from "bcrypt"; import type { FastifyReply, FastifyRequest } from "fastify"; import jwt from "jsonwebtoken"; import { ZodError } from "zod"; @@ -8,59 +9,116 @@ import jwtConfig from "@/config/jwt"; import prisma from "@/database/prisma/client"; import redisClient from "@/database/redis/client"; -import NotFoundError from "../exceptions/notfound.exception"; -import UnauthorizedError from "../exceptions/unauthorized.exception"; -import CredentialSchema from "../schemas/credential.schema"; -import RefreshTokenSchema from "../schemas/refresh-token.schema"; -import SignInSchema from "../schemas/signin.schema"; +import { + ConflictError, + NotFoundError, + UnauthorizedError, +} from "../exceptions/index.exception"; +import { + CredentialSchema, + createSignUpSchema, + RefreshTokenSchema, +} from "../schemas/index.schema"; +import { createTokens } from "../serivces/jwt.service"; import type { JwtSubject } from "../types/jwt"; export default { - login(req: FastifyRequest, reply: FastifyReply) { + async signUp(req: FastifyRequest, reply: FastifyReply) { try { - const { password, ...credentials } = CredentialSchema.parse(req.body); - - // @todo: validate credentials with database - - const user = { - id: crypto.randomUUID(), - email: credentials.email, - username: "@john_doe", - name: "John Doe", - avatarUrl: "https://example.com/avatar.jpg", - roles: ["player"], - }; - - const token = jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour expiration - iat: Math.floor(Date.now() / 1000), - sub: user, + const Schema = createSignUpSchema(req.t); + + const { password, ...credentials } = Schema.parse(req.body); + + const user = await prisma.user.findUnique({ + where: { + email: credentials.email, }, - jwtConfig.secret, - { algorithm: jwtConfig.algorithm }, - ); + }); - const refreshToken = jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour expiration - iat: Math.floor(Date.now() / 1000), - sub: user, - type: "refresh", - tokenId: crypto.randomUUID(), + if (user) { + throw new ConflictError( + req.t("Email already in use", { ns: "errors" }), + ); + } + + const newUser = await prisma.user.create({ + data: { + email: credentials.email, + nickname: credentials.nickname, + password: await bcrypt.hash(password, 10), }, - jwtConfig.secret, - { algorithm: jwtConfig.algorithm }, - ); + select: { + id: true, + email: true, + nickname: true, + }, + }); + + const tokens = createTokens({ + id: newUser.id, + email: newUser.email, + nickname: newUser.nickname, + }); + + return reply.status(201).send({ + message: req.t("User created successfully"), + data: newUser, + tokens, + }); + } catch (e) { + if (e instanceof ZodError) { + return reply.status(400).send({ + error: req.t("Invalid credentials", { ns: "errors" }), + details: e.issues, + }); + } + + if (e instanceof ConflictError) { + return reply.status(e.statusCode).send({ error: e.message }); + } - const response = { - user, - tokens: { - token, - refreshToken, - expiresIn: 60 * 60, // 1 hour in seconds + console.debug((e as Error).message); + return reply + .status(500) + .send({ error: req.t("Server error", { ns: "errors" }) }); + } + }, + + async logInViaSSO(req: FastifyRequest, reply: FastifyReply) { + try { + const credentials = CredentialSchema.parse(req.body); + + const { password, ...user } = await prisma.user.findFirst({ + where: { + email: credentials.email, + }, + select: { + id: true, + email: true, + nickname: true, + password: true, + avatar: true, }, - }; + }); + + if (!user) { + throw new NotFoundError(req.t("User not found")); + } + + const authenticated = await bcrypt.compare( + credentials.password, + password, + ); + + if (!authenticated) { + throw new UnauthorizedError(req.t("Invalid credentials")); + } + + const tokens = createTokens({ + id: user.id, + email: user.email, + nickname: user.nickname, + }); redisClient.set( `session:${user.id}`, @@ -74,44 +132,37 @@ export default { 1 * 24 * 60 * 60, // session long 1 day max ); - reply.send({ message: req.t("Login successful"), data: response }); + reply.send({ + message: req.t("Login successful"), + data: user, + tokens, + }); } catch (e) { - console.debug((e as Error).message); - if (e instanceof ZodError) { - reply.status(400).send({ + return reply.status(400).send({ error: req.t("Invalid credentials"), details: e.issues, }); - } else { - reply.status(500).send({ error: req.t("Server error") }); } - } - }, - - async signin(req: FastifyRequest, reply: FastifyReply) { - try { - const { password, ...credentials } = SignInSchema.parse(req.body); - const userCounter = await prisma.user.count({ - where: { - email: credentials.email, - }, - }); - - if (userCounter > 0) { - throw new UnauthorizedError(req.t("Email already in use")); - } - } catch (e) { if (e instanceof UnauthorizedError) { - reply.status(401).send({ error: e.message }); - } else { - reply.status(500).send({ error: req.t("Server error") }); + return reply.status(e.statusCode).send({ + error: e.message, + details: { + email: req.t("Check your email", { ns: "errors" }), + password: req.t("Check your password", { + ns: "errors", + }), + }, + }); } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); } }, - logout(req: FastifyRequest, reply: FastifyReply) { + logOut(req: FastifyRequest, reply: FastifyReply) { try { const bearerToken = req.headers.authorization; if (!bearerToken) { @@ -162,65 +213,30 @@ export default { throw new UnauthorizedError(req.t("Invalid token type")); } - const user = { - id: crypto.randomUUID(), - email: (decoded.sub as unknown as { email: string }).email, - username: "@john_doe", - name: "John Doe", - avatarUrl: "https://example.com/avatar.jpg", - roles: ["player"], - }; - - const newToken = jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour expiration - iat: Math.floor(Date.now() / 1000), - sub: user, - }, - jwtConfig.secret, - { algorithm: jwtConfig.algorithm }, - ); - - const newRefreshToken = jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour expiration - iat: Math.floor(Date.now() / 1000), - sub: user, - type: "refresh", - tokenId: crypto.randomUUID(), - }, - jwtConfig.secret, - { algorithm: jwtConfig.algorithm }, - ); - - const response = { - tokens: { - token: newToken, - refreshToken: newRefreshToken, - expiresIn: 60 * 60, // 1 hour in seconds - }, - }; + const tokens = createTokens(decoded.sub); reply.send({ message: req.t("Token refreshed successfully"), - data: response, + tokens, + data: decoded.sub, }); } catch (e) { - console.debug((e as Error).message); - if (e instanceof ZodError) { - reply.status(400).send({ + return reply.status(400).send({ error: req.t("Invalid request"), details: e.issues, }); - } else if (e instanceof jwt.JsonWebTokenError) { - reply.status(401).send({ + } + + if (e instanceof jwt.JsonWebTokenError) { + return reply.status(401).send({ error: req.t("Invalid refresh token"), details: e.message, }); - } else { - reply.status(500).send({ error: req.t("Server error") }); } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); } }, @@ -258,25 +274,28 @@ export default { reply.send({ message: req.t("Session revoked") }); } catch (e) { - console.debug((e as Error).message); - if (e instanceof ZodError) { - reply.status(400).send({ + return reply.status(400).send({ error: req.t("Invalid request"), details: e.issues, }); - } else if (e instanceof NotFoundError) { - reply.status(404).send({ error: e.message }); - } else if ( + } + + if (e instanceof NotFoundError) { + return reply.status(404).send({ error: e.message }); + } + + if ( e instanceof jwt.JsonWebTokenError || e instanceof UnauthorizedError ) { - reply + return reply .status(401) .send({ error: req.t("Unauthorized"), details: e.message }); - } else { - reply.status(500).send({ error: req.t("Server error") }); } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); } }, } as const; diff --git a/modules/auth/exceptions/conflict.exception.ts b/modules/auth/exceptions/conflict.exception.ts new file mode 100644 index 0000000..9e649ed --- /dev/null +++ b/modules/auth/exceptions/conflict.exception.ts @@ -0,0 +1,8 @@ +export class ConflictError extends Error { + statusCode: number; + constructor(message: string) { + super(message); + this.name = "ConflictError"; + this.statusCode = 409; + } +} diff --git a/modules/auth/exceptions/index.exception.ts b/modules/auth/exceptions/index.exception.ts new file mode 100644 index 0000000..32d52e6 --- /dev/null +++ b/modules/auth/exceptions/index.exception.ts @@ -0,0 +1,3 @@ +export * from "./conflict.exception"; +export * from "./notfound.exception"; +export * from "./unauthorized.exception"; diff --git a/modules/auth/exceptions/notfound.exception.ts b/modules/auth/exceptions/notfound.exception.ts index 5e68bac..da934cb 100644 --- a/modules/auth/exceptions/notfound.exception.ts +++ b/modules/auth/exceptions/notfound.exception.ts @@ -1,6 +1,8 @@ -export default class NotFoundError extends Error { +export class NotFoundError extends Error { + statusCode: number; constructor(message: string) { super(message); this.name = "NotFoundError"; + this.statusCode = 404; } } diff --git a/modules/auth/exceptions/unauthorized.exception.ts b/modules/auth/exceptions/unauthorized.exception.ts index 29b1ddb..432bd60 100644 --- a/modules/auth/exceptions/unauthorized.exception.ts +++ b/modules/auth/exceptions/unauthorized.exception.ts @@ -1,6 +1,8 @@ -export default class UnauthorizedError extends Error { +export class UnauthorizedError extends Error { + statusCode: number; constructor(message: string) { super(message); this.name = "UnauthorizedError"; + this.statusCode = 401; } } diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index 0167f61..aba98b8 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -3,8 +3,12 @@ import type { FastifyInstance } from "fastify"; import AuthController from "@/modules/auth/controllers/index.controller"; export default async function authRouter(f: FastifyInstance) { - f.post("/login", AuthController.login); - f.get("/logout", AuthController.logout); + f.post("/signup", AuthController.signUp); + f.post("/login/sso", AuthController.logInViaSSO); + // f.post("/login/google", AuthController.login); + // f.post("/login/github", AuthController.login); + // f.post("/login/discord", AuthController.login); f.post("/refresh", AuthController.refresh); f.delete("/revoke", AuthController.revoke); + f.get("/logout", AuthController.logOut); } diff --git a/modules/auth/schemas/credential.schema.ts b/modules/auth/schemas/credential.schema.ts index a8d8478..58cf749 100644 --- a/modules/auth/schemas/credential.schema.ts +++ b/modules/auth/schemas/credential.schema.ts @@ -1,8 +1,6 @@ import { z } from "zod"; -const CredentialSchema = z.object({ +export const CredentialSchema = z.object({ email: z.email(), password: z.string().min(8), }); - -export default CredentialSchema; diff --git a/modules/auth/schemas/index.schema.ts b/modules/auth/schemas/index.schema.ts new file mode 100644 index 0000000..03e6df2 --- /dev/null +++ b/modules/auth/schemas/index.schema.ts @@ -0,0 +1,4 @@ +export * from "./credential.schema"; +export * from "./refresh-token.schema"; +export * from "./signin.schema"; +export * from "./signup.schema"; diff --git a/modules/auth/schemas/refresh-token.schema.ts b/modules/auth/schemas/refresh-token.schema.ts index 8b041fe..a35bd0f 100644 --- a/modules/auth/schemas/refresh-token.schema.ts +++ b/modules/auth/schemas/refresh-token.schema.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -const RefreshTokenSchema = z.object({ +export const RefreshTokenSchema = z.object({ refreshToken: z.string(), }); - -export default RefreshTokenSchema; diff --git a/modules/auth/schemas/signin.schema.ts b/modules/auth/schemas/signin.schema.ts index 495a413..d6788b2 100644 --- a/modules/auth/schemas/signin.schema.ts +++ b/modules/auth/schemas/signin.schema.ts @@ -1,9 +1,7 @@ import { z } from "zod"; -const SignInSchema = z.object({ +export const SignInSchema = z.object({ email: z.email(), username: z.string().min(3).max(30), password: z.string().min(8), }); - -export default SignInSchema; diff --git a/modules/auth/schemas/signup.schema.ts b/modules/auth/schemas/signup.schema.ts new file mode 100644 index 0000000..9444b7a --- /dev/null +++ b/modules/auth/schemas/signup.schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const createSignUpSchema = (t: i18next.TFunction) => { + return z.object({ + email: z.email(), + nickname: z + .string() + // Must start and end with an alphanumeric character, and can contain dots, underscores, or hyphens in between. Consecutive dots, underscores, or hyphens are not allowed. + // Valid: "john_doe", "john.doe", "john-doe", "john123" + // Invalid: "_john", "john_", "john..doe", "john__doe", "john--doe", "john.-doe", "john-.doe" + .regex(/^[a-zA-Z0-9]([._-](?![._-])|[a-zA-Z0-9]){1,14}[a-zA-Z0-9]$/, { + error: t("nickname_rules", { ns: "zod" }), + }) + .min(3) + .max(30), + password: z.string().min(8), + }); +}; diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts new file mode 100644 index 0000000..58351e7 --- /dev/null +++ b/modules/auth/serivces/jwt.service.ts @@ -0,0 +1,39 @@ +import crypto from "node:crypto"; + +import jwt from "jsonwebtoken"; + +import jwtConfig from "@/config/jwt"; + +export const createTokens = (sub: unknown) => { + const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour expiration + const iat = Math.floor(Date.now() / 1000); + + const accessToken = jwt.sign( + { + exp, + iat, + sub, + }, + jwtConfig.secret, + { algorithm: jwtConfig.algorithm }, + ); + + const refreshToken = jwt.sign( + { + exp, + iat, + sub, + type: "refresh", + tokenId: crypto.randomUUID(), + }, + jwtConfig.secret, + { algorithm: jwtConfig.algorithm }, + ); + + return { + accessToken, + refreshToken, + expiresIn: 60 * 60, // 1 hour in seconds + issuedAt: iat, + }; +}; diff --git a/modules/users/controllers/index.controller.ts b/modules/users/controllers/index.controller.ts index e69de29..210ba6b 100644 --- a/modules/users/controllers/index.controller.ts +++ b/modules/users/controllers/index.controller.ts @@ -0,0 +1,20 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + +import prisma from "@/database/prisma/client"; + +// export default { +// async uploadAvatar(req: FastifyRequest, reply: FastifyReply) { +// const userId = req.user.id; + +// if (!req.file) { +// return reply.status(400).send({ error: req.t("No file uploaded") }); +// } + +// const avatarUrl = `/uploads/${req.file.filename}`; + +// try { +// await prisma.image.create({ + +// }); +// } +// } as const; diff --git a/package.json b/package.json index ded3ad5..08d7f82 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "bcrypt": "^6.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", "i18next": "^26.0.3", @@ -38,6 +39,7 @@ "@biomejs/biome": "2.4.10", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", + "@types/bcrypt": "^6.0.0", "@types/bun": "^1.3.11", "@types/jsonwebtoken": "^9.0.10", "@typescript/native-preview": "^7.0.0-dev.20260403.1", diff --git a/plugins/i18n.ts b/plugins/i18n.ts index b800d35..ba634c2 100644 --- a/plugins/i18n.ts +++ b/plugins/i18n.ts @@ -27,9 +27,13 @@ export default fp(async (f) => { // expose t() in Fastify request // @ts-expect-error f.decorateRequest("t", null); + // @ts-expect-error + f.decorateRequest("language", null); f.addHook("preHandler", async (req) => { // @ts-expect-error req.t = req.raw.t; + // @ts-expect-error + req.language = req.raw.language; }); }); diff --git a/plugins/jwt.ts b/plugins/jwt.ts new file mode 100644 index 0000000..dd2459c --- /dev/null +++ b/plugins/jwt.ts @@ -0,0 +1,62 @@ +import fp from "fastify-plugin"; +import jwt from "jsonwebtoken"; + +import jwtConfig from "@/config/jwt"; +import redisClient from "@/database/redis/client"; + +export default fp(async (f) => { + f.decorateRequest("user", null); + + f.addHook("preHandler", async (req, reply) => { + const { pathname } = new URL(req.url, `http://${req.headers.host}`); + + // Skip authentication for public routes + if (publicRoutes.some((route) => pathname.startsWith(route))) { + return; + } + + const authHeader = req.headers.authorization; + + if (!authHeader) { + throw new jwt.JsonWebTokenError(req.t("Header missing")); + } + + const token = authHeader.split(" ")[1]; + + if (!token) { + throw new jwt.JsonWebTokenError(req.t("No token provided")); + } + + try { + const decoded = jwt.verify(token, jwtConfig.secret, { + algorithms: [jwtConfig.algorithm], + }) as jwt.JwtPayload; + + const sub = decoded.sub as unknown as { + id: number; + email: string; + nickname: string; + }; + + const session = await redisClient.get(`session:${sub.id}`); + + if (!session) { + throw new jwt.JsonWebTokenError(req.t("Session expired or invalid")); + } + + // @ts-expect-error + req.user = JSON.parse(session); + } catch (e) { + if (e instanceof jwt.JsonWebTokenError) { + return reply + .status(401) + .send({ error: req.t("Unauthorized"), details: e.message }); + } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); + } + }); +}); + +const publicRoutes = ["/1/auth/signup", "/1/auth/login"]; diff --git a/plugins/zod.ts b/plugins/zod.ts new file mode 100644 index 0000000..83faf4d --- /dev/null +++ b/plugins/zod.ts @@ -0,0 +1,7 @@ +import fp from "fastify-plugin"; + +export default fp(async (f) => { + f.addHook("preHandler", async (req) => { + const lng = req.query; + }); +}); From d0c0c5abdd28a379668e2f4da8b585140d388f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 12:26:08 -0300 Subject: [PATCH 03/11] feat(Root): native login + google oauth2 login --- bun.lock | 79 ++++++ compose.yml | 1 + .../migrations/20260502141005/migration.sql | 14 + .../migrations/20260502151239/migration.sql | 5 + database/prisma/schema.prisma | 31 ++- database/redis/client.ts | 9 +- .../conflict.exception.ts | 0 .../index.exception.ts | 0 .../notfound.exception.ts | 0 .../unauthorized.exception.ts | 0 .../controllers/google-oauth.controller.ts | 202 +++++++++++++++ modules/auth/controllers/index.controller.ts | 244 +++++++++++++----- modules/auth/models/Auth.d.ts | 0 modules/auth/models/Session.d.ts | 14 + modules/auth/routes/index.router.ts | 24 +- modules/auth/schemas/credential.schema.ts | 19 +- modules/auth/schemas/google-user.schema.ts | 8 + modules/auth/schemas/index.schema.ts | 1 + modules/auth/serivces/google.service.ts | 1 + modules/auth/serivces/jwt.service.ts | 8 +- modules/auth/types/jwt.d.ts | 20 +- modules/users/models/User.d.ts | 6 + package.json | 1 + plugins/jwt.ts | 24 +- 24 files changed, 601 insertions(+), 110 deletions(-) create mode 100644 database/prisma/migrations/20260502141005/migration.sql create mode 100644 database/prisma/migrations/20260502151239/migration.sql rename {modules/auth/exceptions => exceptions}/conflict.exception.ts (100%) rename {modules/auth/exceptions => exceptions}/index.exception.ts (100%) rename {modules/auth/exceptions => exceptions}/notfound.exception.ts (100%) rename {modules/auth/exceptions => exceptions}/unauthorized.exception.ts (100%) create mode 100644 modules/auth/controllers/google-oauth.controller.ts delete mode 100644 modules/auth/models/Auth.d.ts create mode 100644 modules/auth/models/Session.d.ts create mode 100644 modules/auth/schemas/google-user.schema.ts create mode 100644 modules/auth/serivces/google.service.ts diff --git a/bun.lock b/bun.lock index 48432a2..b739060 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "bcrypt": "^6.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", + "googleapis": "^171.4.0", "i18next": "^26.0.3", "i18next-fs-backend": "^2.6.3", "i18next-http-middleware": "^3.9.2", @@ -195,6 +196,8 @@ "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -213,16 +216,24 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], "better-result": ["better-result@2.8.2", "", {}, "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], @@ -255,6 +266,10 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], @@ -269,6 +284,8 @@ "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], @@ -281,10 +298,18 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -303,32 +328,62 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "giget": ["giget@3.2.0", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="], "git-raw-commits": ["git-raw-commits@5.0.1", "", { "dependencies": { "@conventional-changelog/git-client": "^2.6.0", "meow": "^13.0.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ=="], "global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="], + "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "googleapis": ["googleapis@171.4.0", "", { "dependencies": { "google-auth-library": "^10.2.0", "googleapis-common": "^8.0.0" } }, "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg=="], + + "googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "i18next": ["i18next@26.0.8", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw=="], "i18next-fs-backend": ["i18next-fs-backend@2.6.5", "", {}, "sha512-+7HBrlaj3UFnNC9HqiXaIlNkwNINYkgQ2PS4h7qW/WGHC9/+OU9Xi0/tdvjjXATw3YCcpmNV7S3zDD9e10pTWg=="], @@ -365,6 +420,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], @@ -433,6 +490,8 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -445,8 +504,14 @@ "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -505,6 +570,8 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], @@ -555,6 +622,14 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -579,8 +654,12 @@ "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], diff --git a/compose.yml b/compose.yml index 6a67162..d68432e 100644 --- a/compose.yml +++ b/compose.yml @@ -4,6 +4,7 @@ services: api: container_name: dojoh-api image: dojoh-api:latest + restart: unless-stopped build: dockerfile: ci/Dockerfile.dev context: . diff --git a/database/prisma/migrations/20260502141005/migration.sql b/database/prisma/migrations/20260502141005/migration.sql new file mode 100644 index 0000000..5f7a9c2 --- /dev/null +++ b/database/prisma/migrations/20260502141005/migration.sql @@ -0,0 +1,14 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "OAuthProvider" ADD VALUE 'DISCORD'; +ALTER TYPE "OAuthProvider" ADD VALUE 'NATIVE'; + +-- AlterTable +ALTER TABLE "OAuthAccount" ADD COLUMN "provider_metadata" JSONB, +ALTER COLUMN "provider_avatar_url" DROP NOT NULL; diff --git a/database/prisma/migrations/20260502151239/migration.sql b/database/prisma/migrations/20260502151239/migration.sql new file mode 100644 index 0000000..cdd9690 --- /dev/null +++ b/database/prisma/migrations/20260502151239/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "OAuthAccount" ALTER COLUMN "user_id" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "OAuthAccount" ADD CONSTRAINT "OAuthAccount_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/database/prisma/schema.prisma b/database/prisma/schema.prisma index 29b918a..71b88b9 100644 --- a/database/prisma/schema.prisma +++ b/database/prisma/schema.prisma @@ -43,16 +43,17 @@ model ImageVariant { } model User { - id Int @id @default(autoincrement()) - email String @unique - nickname String @unique - password String - avatar Image? @relation(fields: [avatar_id], references: [id]) + id Int @id @default(autoincrement()) + email String @unique + nickname String @unique + password String? + avatar Image? @relation(fields: [avatar_id], references: [id]) avatar_id Int? - created_at DateTime @default(now()) - updated_at DateTime? @updatedAt - deleted_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime? @updatedAt + deleted_at DateTime? + oauthAccounts OAuthAccount[] @@index([email]) @@index([nickname]) @@ -60,22 +61,26 @@ model User { } model OAuthAccount { - id Int @id @default(autoincrement()) - user_id Int + id Int @id @default(autoincrement()) + user_id Int? // Allow null for accounts that are not linked to a user yet + user User? @relation(fields: [user_id], references: [id], onDelete: SetNull) provider OAuthProvider provider_user_id String provider_username String - provider_avatar_url String + provider_avatar_url String? + provider_metadata Json? created_at DateTime @default(now()) updated_at DateTime? @updatedAt - @@unique([user_id, provider]) - @@unique([provider, provider_user_id]) + @@unique([user_id, provider], name: "user_provider_idx") + @@unique([provider, provider_user_id], name: "provider_user_id_idx") } enum OAuthProvider { GOOGLE GITHUB + DISCORD + NATIVE } diff --git a/database/redis/client.ts b/database/redis/client.ts index 1e882d9..ca04fe5 100644 --- a/database/redis/client.ts +++ b/database/redis/client.ts @@ -2,10 +2,11 @@ import { RedisClient } from "bun"; import databaseConfig from "@/config/database"; -const { redis } = databaseConfig; +const { redis: redisConfig } = databaseConfig; +const { host, port, username, password } = redisConfig; -const client = new RedisClient( - `redis://${redis.username}:${redis.password}@${redis.host}:${redis.port}`, +const redis = new RedisClient( + `redis://${username}:${password}@${host}:${port}`, ); -export default client; +export default redis; diff --git a/modules/auth/exceptions/conflict.exception.ts b/exceptions/conflict.exception.ts similarity index 100% rename from modules/auth/exceptions/conflict.exception.ts rename to exceptions/conflict.exception.ts diff --git a/modules/auth/exceptions/index.exception.ts b/exceptions/index.exception.ts similarity index 100% rename from modules/auth/exceptions/index.exception.ts rename to exceptions/index.exception.ts diff --git a/modules/auth/exceptions/notfound.exception.ts b/exceptions/notfound.exception.ts similarity index 100% rename from modules/auth/exceptions/notfound.exception.ts rename to exceptions/notfound.exception.ts diff --git a/modules/auth/exceptions/unauthorized.exception.ts b/exceptions/unauthorized.exception.ts similarity index 100% rename from modules/auth/exceptions/unauthorized.exception.ts rename to exceptions/unauthorized.exception.ts diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts new file mode 100644 index 0000000..9b4a8cc --- /dev/null +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -0,0 +1,202 @@ +import crypto from "node:crypto"; + +import { OAuthProvider } from "@prisma/client"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { google } from "googleapis"; + +import env from "@/config/env"; +import prisma from "@/database/prisma/client"; +import redis from "@/database/redis/client"; + +import type { Session } from "../models/Session"; +import { GoogleUserSchema } from "../schemas/google-user.schema"; +import { createTokens } from "../serivces/jwt.service"; + +/** + * To use OAuth2 authentication, we need access to a CLIENT_ID, CLIENT_SECRET, AND REDIRECT_URI + * from the client_secret.json file. To get these credentials for your application, visit + * https://console.cloud.google.com/apis/credentials. + */ +const oauth2Client = new google.auth.OAuth2( + { + clientId: env("GOOGLE_CLIENT_ID") as string, + }, + env("GOOGLE_CLIENT_SECRET") as string, + env("GOOGLE_REDIRECT_URI") as string, +); + +export const googleOauth = { + callback: async (req: FastifyRequest, reply: FastifyReply) => { + const { code, state, error } = req.query as { + code: string; + state: string; + error?: string; + }; + + if (error) { + req.log.warn("OAuth error: " + error); + return reply.status(400).send("OAuth error: " + error); + } + + // @ts-expect-error + if (state !== req.state) { + req.log.warn("State mismatch. Possible CSRF attack"); + return reply.status(400).send("State mismatch. Possible CSRF attack"); + } + + const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + const oauth2 = google.oauth2({ + auth: oauth2Client, + version: "v2", + }); + + const { data } = await oauth2.userinfo.get(); + const googleUser = GoogleUserSchema.parse(data); + + if (!googleUser.id) { + req.log.warn("Google user ID not found"); + return reply.status(400).send("Google user ID not found"); + } + + const oauthAccount = await prisma.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.GOOGLE, + provider_user_id: googleUser.id, + }, + }, + update: { + provider_username: googleUser.name, + provider_avatar_url: googleUser.picture, + provider_metadata: { + email: googleUser.email, + }, + }, + create: { + provider: OAuthProvider.GOOGLE, + user_id: null, // Will be linked to a user account later + provider_user_id: googleUser.id, + provider_username: googleUser.name, + provider_avatar_url: googleUser.picture, + provider_metadata: { + email: googleUser.email, + }, + }, + }); + + const user = await prisma.user.upsert({ + where: { + email: googleUser.email, + }, + update: { + // Do nothing, we don't want to overwrite existing user data + }, + create: { + email: googleUser.email, + nickname: googleUser.name, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + }, + }); + + // Not need to wait for this, if it fails we can just log it and move on + prisma.oAuthAccount + .update({ + where: { + id: oauthAccount.id, + }, + data: { + user_id: user.id, + }, + }) + .catch((e) => { + req.log.warn( + "Failed to link OAuth account to user: " + + JSON.stringify( + { + userId: user.id, + oauthAccountId: oauthAccount.id, + error: (e as Error).message, + }, + null, + 2, + ), + ); + }); + + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { + id: user.id, + email: user.email, + name: user.nickname, + nickname: user.nickname, + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.GOOGLE, + accessToken: tokens.access_token || undefined, + refreshToken: tokens.refresh_token || undefined, + }, + }; + + redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); + + return reply.send({ + message: req.t("Logged in with Google successfully!"), + tokens: jwtTokens, + data: user, + }); + }, + generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + // Access scopes for two non-Sign-In scopes: Read-only Drive activity and Google Calendar. + const scopes: Array = [ + // "https://www.googleapis.com/auth/drive.metadata.readonly", + // "https://www.googleapis.com/auth/calendar.readonly", + ]; + + // Generate a secure random state value. + const state = crypto.randomBytes(32).toString("hex"); + + // Store state in the session + // @ts-expect-error + req.state = state; + + // Generate a url that asks permissions for the Drive activity and Google Calendar scope + const authorizationUrl = oauth2Client.generateAuthUrl({ + // 'online' (default) or 'offline' (gets refresh_token) + access_type: "offline", + /** Pass in the scopes array defined above. + * Alternatively, if only one scope is needed, you can pass a scope URL as a string */ + scope: scopes, + // Enable incremental authorization. Recommended as a best practice. + include_granted_scopes: true, + // Include the state parameter to reduce the risk of CSRF attacks. + state: state, + }); + + return reply.redirect(authorizationUrl); + }, +}; diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index 0a2d16f..35c7b87 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import { OAuthProvider, type User } from "@prisma/client"; import bcrypt from "bcrypt"; import type { FastifyReply, FastifyRequest } from "fastify"; import jwt from "jsonwebtoken"; @@ -7,15 +8,16 @@ import { ZodError } from "zod"; import jwtConfig from "@/config/jwt"; import prisma from "@/database/prisma/client"; -import redisClient from "@/database/redis/client"; - +import redis from "@/database/redis/client"; import { ConflictError, NotFoundError, UnauthorizedError, -} from "../exceptions/index.exception"; +} from "@/exceptions/index.exception"; + +import type { Session } from "../models/Session"; import { - CredentialSchema, + createCredentialSchema, createSignUpSchema, RefreshTokenSchema, } from "../schemas/index.schema"; @@ -23,21 +25,29 @@ import { createTokens } from "../serivces/jwt.service"; import type { JwtSubject } from "../types/jwt"; export default { - async signUp(req: FastifyRequest, reply: FastifyReply) { + async signup(req: FastifyRequest, reply: FastifyReply) { try { - const Schema = createSignUpSchema(req.t); - - const { password, ...credentials } = Schema.parse(req.body); + const { password, ...credentials } = createSignUpSchema(req.t).parse( + req.body, + ); - const user = await prisma.user.findUnique({ + const user = await prisma.user.findFirst({ where: { - email: credentials.email, + OR: [ + { email: credentials.email }, + { nickname: credentials.nickname }, + ], + }, + select: { + id: true, }, }); if (user) { throw new ConflictError( - req.t("Email already in use", { ns: "errors" }), + req.t("Email or Nickename already in use", { + ns: "errors", + }), ); } @@ -54,16 +64,38 @@ export default { }, }); - const tokens = createTokens({ - id: newUser.id, - email: newUser.email, - nickname: newUser.nickname, - }); + // Not need to wait for this, if it fails we can just log it and move on + prisma.oAuthAccount + .create({ + data: { + user_id: newUser.id, + + provider: OAuthProvider.NATIVE, + provider_user_id: newUser.id.toString(), + provider_username: newUser.nickname, + // provider_avatar_url: null, + provider_metadata: { + email: newUser.email, + }, + }, + }) + .catch((e) => { + req.log.warn( + "Failed to create OAuth account for user: " + + JSON.stringify( + { + userId: newUser.id, + error: (e as Error).message, + }, + null, + 2, + ), + ); + }); return reply.status(201).send({ message: req.t("User created successfully"), data: newUser, - tokens, }); } catch (e) { if (e instanceof ZodError) { @@ -84,13 +116,16 @@ export default { } }, - async logInViaSSO(req: FastifyRequest, reply: FastifyReply) { + async login(req: FastifyRequest, reply: FastifyReply) { try { - const credentials = CredentialSchema.parse(req.body); + const credentials = createCredentialSchema(req.t).parse(req.body); - const { password, ...user } = await prisma.user.findFirst({ + const user = await prisma.user.findFirst({ where: { - email: credentials.email, + OR: [ + { email: credentials.identifier }, + { nickname: credentials.identifier }, + ], }, select: { id: true, @@ -107,35 +142,51 @@ export default { const authenticated = await bcrypt.compare( credentials.password, - password, + user.password!, ); + delete (user as { password?: string }).password; + if (!authenticated) { throw new UnauthorizedError(req.t("Invalid credentials")); } - const tokens = createTokens({ + const tokens = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { id: user.id, email: user.email, + name: user.nickname, nickname: user.nickname, - }); + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId: tokens.refreshId, + provider: { + type: OAuthProvider.NATIVE, + }, + }; - redisClient.set( + redis.set( `session:${user.id}`, - JSON.stringify({ - ...user, - ipv4: req.ip, - userAgent: req.headers["user-agent"], - version: 1, - }), + JSON.stringify(session), "EX", 1 * 24 * 60 * 60, // session long 1 day max ); - reply.send({ + return reply.status(200).send({ message: req.t("Login successful"), - data: user, tokens, + data: user, }); } catch (e) { if (e instanceof ZodError) { @@ -157,12 +208,16 @@ export default { }); } + if (e instanceof NotFoundError) { + return reply.status(e.statusCode).send({ error: e.message }); + } + console.debug((e as Error).message); return reply.status(500).send({ error: req.t("Server error") }); } }, - logOut(req: FastifyRequest, reply: FastifyReply) { + async logout(req: FastifyRequest, reply: FastifyReply) { try { const bearerToken = req.headers.authorization; if (!bearerToken) { @@ -177,32 +232,28 @@ export default { const decoded = jwt.verify(token, jwtConfig.secret, { algorithms: [jwtConfig.algorithm], }) as jwt.JwtPayload; - const sub = decoded.sub as unknown as { id: string }; + const sub = decoded.sub as unknown as JwtSubject; - redisClient.del(`session:${sub.id}`); + await redis.del(`session:${sub.id}`); reply.status(204).send(); } catch (e) { - console.debug((e as Error).message); - if ( e instanceof jwt.JsonWebTokenError || e instanceof UnauthorizedError ) { - reply + return reply .status(401) .send({ error: req.t("Unauthorized"), details: e.message }); - } else { - reply.status(500).send({ error: req.t("Server error") }); } + + console.debug((e as Error).message); + return reply.status(500).send({ error: req.t("Server error") }); } }, - refresh(req: FastifyRequest, reply: FastifyReply) { + async refresh(req: FastifyRequest, reply: FastifyReply) { try { - // @todo check if session exist in database - // and validate tokenId with the one in database to prevent reuse of refresh token - const { refreshToken } = RefreshTokenSchema.parse(req.body); const decoded = jwt.verify(refreshToken, jwtConfig.secret, { @@ -213,12 +264,77 @@ export default { throw new UnauthorizedError(req.t("Invalid token type")); } - const tokens = createTokens(decoded.sub); + const subject = decoded.sub as unknown as JwtSubject; + + if (!subject.id) { + throw new UnauthorizedError(req.t("Invalid token subject")); + } + + const refreshId = decoded.jti as string; + + const sessionId = `session:${subject.id}`; + + const rawUserSession = await redis.get(sessionId); + + if (!rawUserSession) { + throw new NotFoundError(req.t("Session not found")); + } + + const userSession = JSON.parse(rawUserSession) as { + refreshId: string; + version: number; + }; + + if (userSession.refreshId !== refreshId) { + throw new UnauthorizedError(req.t("Invalid refresh token")); + } + + const newVersion = userSession.version + 1; + + const tokens = createTokens({ ...subject }, newVersion); + + const user = await prisma.user.findUnique({ + where: { + id: Number(subject.id), + }, + select: { + id: true, + email: true, + nickname: true, + }, + }); + + if (!user) { + throw new NotFoundError(req.t("User not found")); + } + + const session: Session = { + id: user.id, + email: user.email, + name: user.nickname, + nickname: user.nickname, + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: newVersion, + refreshId: tokens.refreshId, + provider: { + type: OAuthProvider.NATIVE, + }, + }; + + await redis.set( + sessionId, + JSON.stringify(session), + "EX", + 1 * 24 * 60 * 60, // session long 1 day max + ); - reply.send({ + return reply.status(200).send({ message: req.t("Token refreshed successfully"), tokens, - data: decoded.sub, + data: user, }); } catch (e) { if (e instanceof ZodError) { @@ -235,6 +351,14 @@ export default { }); } + if (e instanceof NotFoundError) { + return reply.status(404).send({ error: e.message }); + } + + if (e instanceof UnauthorizedError) { + return reply.status(401).send({ error: e.message }); + } + console.debug((e as Error).message); return reply.status(500).send({ error: req.t("Server error") }); } @@ -257,22 +381,20 @@ export default { }) as jwt.JwtPayload; const { id: userId } = decoded.sub as unknown as JwtSubject; - if (!redisClient.exists(`session:${userId}`)) { + if (!redis.exists(`session:${userId}`)) { throw new NotFoundError(req.t("Session not found")); } - const rawUserSession = await redisClient.get(`session:${userId}`); - const userSession = JSON.parse(rawUserSession as string); - - redisClient.set( - `session:${userId}`, - JSON.stringify({ - ...userSession, - version: userSession.version + 1, - }), - "KEEPTTL", - ); + const rawSession = await redis.get(`session:${userId}`); + const session = JSON.parse(rawSession as string) as Session; + + const updatedSession: Session = { + ...session, + v: session.v + 1, + }; + + redis.set(`session:${userId}`, JSON.stringify(updatedSession), "KEEPTTL"); - reply.send({ message: req.t("Session revoked") }); + return reply.status(204).send(); } catch (e) { if (e instanceof ZodError) { return reply.status(400).send({ diff --git a/modules/auth/models/Auth.d.ts b/modules/auth/models/Auth.d.ts deleted file mode 100644 index e69de29..0000000 diff --git a/modules/auth/models/Session.d.ts b/modules/auth/models/Session.d.ts new file mode 100644 index 0000000..a6b00bc --- /dev/null +++ b/modules/auth/models/Session.d.ts @@ -0,0 +1,14 @@ +import type { User } from "@/modules/users/models/User"; +import type { OAuthProvider } from "@prisma/client"; + +export interface Session extends User { + ipv4: `${number}.${number}.${number}.${number}`; + refreshId: string; + userAgent: string; + v: number; + provider: { + type: OAuthProvider; + refreshToken?: string; + accessToken?: string; + }; +} diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index aba98b8..bd4ad1e 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -2,13 +2,25 @@ import type { FastifyInstance } from "fastify"; import AuthController from "@/modules/auth/controllers/index.controller"; +import { googleOauth } from "../controllers/google-oauth.controller"; + export default async function authRouter(f: FastifyInstance) { - f.post("/signup", AuthController.signUp); - f.post("/login/sso", AuthController.logInViaSSO); - // f.post("/login/google", AuthController.login); - // f.post("/login/github", AuthController.login); - // f.post("/login/discord", AuthController.login); + f.post("/signup", AuthController.signup); + + f.post("/login", AuthController.login); + f.post("/refresh", AuthController.refresh); + f.delete("/revoke", AuthController.revoke); - f.get("/logout", AuthController.logOut); + + f.delete("/logout", AuthController.logout); + + f.get("/url/google", googleOauth.generateUrl); + f.get("/callback/google", googleOauth.callback); + + // f.get("/url/github", githubOauth.generateUrl); + // f.get("/callback/github", githubOauth.callback); + + // f.get("/url/discord", discordOauth.generateUrl); + // f.get("/callback/discord", discordOauth.callback); } diff --git a/modules/auth/schemas/credential.schema.ts b/modules/auth/schemas/credential.schema.ts index 58cf749..8ced8b4 100644 --- a/modules/auth/schemas/credential.schema.ts +++ b/modules/auth/schemas/credential.schema.ts @@ -1,6 +1,17 @@ import { z } from "zod"; -export const CredentialSchema = z.object({ - email: z.email(), - password: z.string().min(8), -}); +export const createCredentialSchema = (t: i18next.TFunction) => { + return z.object({ + identifier: z.email().or( + z + .string() + // Must start and end with an alphanumeric character, and can contain dots, underscores, or hyphens in between. Consecutive dots, underscores, or hyphens are not allowed. + // Valid: "john_doe", "john.doe", "john-doe", "john123" + // Invalid: "_john", "john_", "john..doe", "john__doe", "john--doe", "john.-doe", "john-.doe" + .regex(/^[a-zA-Z0-9]([._-](?![._-])|[a-zA-Z0-9]){1,14}[a-zA-Z0-9]$/, { + error: t("nickname_rules", { ns: "zod" }), + }), + ), + password: z.string().min(8), + }); +}; diff --git a/modules/auth/schemas/google-user.schema.ts b/modules/auth/schemas/google-user.schema.ts new file mode 100644 index 0000000..4432045 --- /dev/null +++ b/modules/auth/schemas/google-user.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const GoogleUserSchema = z.object({ + id: z.string(), + email: z.email(), + name: z.string(), + picture: z.url().optional(), +}); diff --git a/modules/auth/schemas/index.schema.ts b/modules/auth/schemas/index.schema.ts index 03e6df2..5d4740d 100644 --- a/modules/auth/schemas/index.schema.ts +++ b/modules/auth/schemas/index.schema.ts @@ -1,4 +1,5 @@ export * from "./credential.schema"; +export * from "./google-user.schema"; export * from "./refresh-token.schema"; export * from "./signin.schema"; export * from "./signup.schema"; diff --git a/modules/auth/serivces/google.service.ts b/modules/auth/serivces/google.service.ts new file mode 100644 index 0000000..8b88f0a --- /dev/null +++ b/modules/auth/serivces/google.service.ts @@ -0,0 +1 @@ +export namespace GoogleOauth {} diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts index 58351e7..cf02f53 100644 --- a/modules/auth/serivces/jwt.service.ts +++ b/modules/auth/serivces/jwt.service.ts @@ -4,15 +4,18 @@ import jwt from "jsonwebtoken"; import jwtConfig from "@/config/jwt"; -export const createTokens = (sub: unknown) => { +export const createTokens = (sub: unknown, version: number = 1) => { const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour expiration const iat = Math.floor(Date.now() / 1000); + const jti = crypto.randomUUID(); const accessToken = jwt.sign( { exp, iat, sub, + v: version, + type: "access", }, jwtConfig.secret, { algorithm: jwtConfig.algorithm }, @@ -24,7 +27,7 @@ export const createTokens = (sub: unknown) => { iat, sub, type: "refresh", - tokenId: crypto.randomUUID(), + jti, }, jwtConfig.secret, { algorithm: jwtConfig.algorithm }, @@ -35,5 +38,6 @@ export const createTokens = (sub: unknown) => { refreshToken, expiresIn: 60 * 60, // 1 hour in seconds issuedAt: iat, + refreshId: jti, }; }; diff --git a/modules/auth/types/jwt.d.ts b/modules/auth/types/jwt.d.ts index 906c090..8bd0f6f 100644 --- a/modules/auth/types/jwt.d.ts +++ b/modules/auth/types/jwt.d.ts @@ -1,15 +1,15 @@ -export interface JwtPayload { - sub: JwtSubject; - email: string; - iat: number; - exp: number; -} - export interface JwtSubject { id: string; email: string; - username: string; + nickname: string; name: string; - avatarUrl: string; - roles: string[]; + version: number; +} + +export interface JwtPayload { + sub: JwtSubject; + iat: number; + exp: number; + jti?: string; + type: "access" | "refresh"; } diff --git a/modules/users/models/User.d.ts b/modules/users/models/User.d.ts index e69de29..3119ada 100644 --- a/modules/users/models/User.d.ts +++ b/modules/users/models/User.d.ts @@ -0,0 +1,6 @@ +export interface User { + id: number; + email: string; + nickname: string; + name: string; +} diff --git a/package.json b/package.json index 08d7f82..92cd1fb 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bcrypt": "^6.0.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", + "googleapis": "^171.4.0", "i18next": "^26.0.3", "i18next-fs-backend": "^2.6.3", "i18next-http-middleware": "^3.9.2", diff --git a/plugins/jwt.ts b/plugins/jwt.ts index dd2459c..93eff85 100644 --- a/plugins/jwt.ts +++ b/plugins/jwt.ts @@ -2,7 +2,9 @@ import fp from "fastify-plugin"; import jwt from "jsonwebtoken"; import jwtConfig from "@/config/jwt"; -import redisClient from "@/database/redis/client"; +import redis from "@/database/redis/client"; +import type { Session } from "@/modules/auth/models/Session"; +import type { JwtSubject } from "@/modules/auth/types/jwt"; export default fp(async (f) => { f.decorateRequest("user", null); @@ -32,24 +34,26 @@ export default fp(async (f) => { algorithms: [jwtConfig.algorithm], }) as jwt.JwtPayload; - const sub = decoded.sub as unknown as { - id: number; - email: string; - nickname: string; - }; + const subject = decoded.sub as unknown as JwtSubject; - const session = await redisClient.get(`session:${sub.id}`); + const rawSession = await redis.get(`session:${subject.id}`); - if (!session) { + if (!rawSession) { throw new jwt.JsonWebTokenError(req.t("Session expired or invalid")); } + const session = JSON.parse(rawSession) as Session; + + if (decoded.v !== session.v) { + throw new jwt.JsonWebTokenError(req.t("Session was invalidated")); + } + // @ts-expect-error - req.user = JSON.parse(session); + req.user = session; } catch (e) { if (e instanceof jwt.JsonWebTokenError) { return reply - .status(401) + .status(403) .send({ error: req.t("Unauthorized"), details: e.message }); } From 6b9f84a40298e2b2db3e7a279d87cee1ec3bc652 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 16:01:53 +0000 Subject: [PATCH 04/11] fix(jwt.service): read token lifetimes from config; add +10s grace on refresh token Agent-Logs-Url: https://github.com/dojoh-dev/api/sessions/e51ba94d-b8f5-4c51-8fb6-1f6c2da7b64f Co-authored-by: itssimmons <62354548+itssimmons@users.noreply.github.com> --- config/jwt.ts | 4 ++-- modules/auth/serivces/jwt.service.ts | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/config/jwt.ts b/config/jwt.ts index bfad069..b4cfa46 100644 --- a/config/jwt.ts +++ b/config/jwt.ts @@ -6,10 +6,10 @@ export default { secret: env("JWT_SECRET", "default_secret"), access: { - expiresIn: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour in seconds + expiresIn: 60 * 60, // 1 hour in seconds }, refresh: { - expiresIn: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days in seconds + expiresIn: 60 * 60, // 1 hour in seconds }, } satisfies JwtConfig; diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts index cf02f53..8d7ecd4 100644 --- a/modules/auth/serivces/jwt.service.ts +++ b/modules/auth/serivces/jwt.service.ts @@ -4,15 +4,18 @@ import jwt from "jsonwebtoken"; import jwtConfig from "@/config/jwt"; +const REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 10; + export const createTokens = (sub: unknown, version: number = 1) => { - const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour expiration - const iat = Math.floor(Date.now() / 1000); + const now = Math.floor(Date.now() / 1000); + const accessExp = now + jwtConfig.access.expiresIn; + const refreshExp = now + jwtConfig.refresh.expiresIn + REFRESH_TOKEN_GRACE_PERIOD_SECONDS; const jti = crypto.randomUUID(); const accessToken = jwt.sign( { - exp, - iat, + exp: accessExp, + iat: now, sub, v: version, type: "access", @@ -23,8 +26,8 @@ export const createTokens = (sub: unknown, version: number = 1) => { const refreshToken = jwt.sign( { - exp, - iat, + exp: refreshExp, + iat: now, sub, type: "refresh", jti, @@ -36,8 +39,8 @@ export const createTokens = (sub: unknown, version: number = 1) => { return { accessToken, refreshToken, - expiresIn: 60 * 60, // 1 hour in seconds - issuedAt: iat, + expiresIn: jwtConfig.access.expiresIn, + issuedAt: now, refreshId: jti, }; }; From 984d71495e25289bb1d037605dd6baf1fa1008b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 16:08:19 +0000 Subject: [PATCH 05/11] fix(auth): return 401 instead of 500 when OAuth user has no password on native login Agent-Logs-Url: https://github.com/dojoh-dev/api/sessions/f56905a4-f168-4cf2-9242-dd5ea3a6ad28 Co-authored-by: itssimmons <62354548+itssimmons@users.noreply.github.com> --- modules/auth/controllers/index.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index 35c7b87..bd0e1b2 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -136,13 +136,13 @@ export default { }, }); - if (!user) { - throw new NotFoundError(req.t("User not found")); + if (!user || !user.password) { + throw new UnauthorizedError(req.t("Invalid credentials")); } const authenticated = await bcrypt.compare( credentials.password, - user.password!, + user.password, ); delete (user as { password?: string }).password; From 52cdf2558741960c0fb20d5350e19d7e9daca0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 16:51:18 -0300 Subject: [PATCH 06/11] feat(auth): github & discord oauth2 integration --- compose.yml | 3 +- .../controllers/discord-oauth.controller.ts | 237 ++++++++++++++++++ .../controllers/github-oauth.controller.ts | 235 +++++++++++++++++ .../controllers/google-oauth.controller.ts | 14 +- modules/auth/controllers/index.controller.ts | 15 +- modules/auth/routes/index.router.ts | 19 +- modules/auth/schemas/discord-user.schema.ts | 10 + modules/auth/schemas/github-user.schema.ts | 9 + modules/users/controllers/.gitkeep | 0 modules/users/controllers/index.controller.ts | 20 -- plugins/zod.ts | 2 +- shared/utils/crypto.ts | 24 ++ 12 files changed, 544 insertions(+), 44 deletions(-) create mode 100644 modules/auth/controllers/discord-oauth.controller.ts create mode 100644 modules/auth/controllers/github-oauth.controller.ts create mode 100644 modules/auth/schemas/discord-user.schema.ts create mode 100644 modules/auth/schemas/github-user.schema.ts create mode 100644 modules/users/controllers/.gitkeep delete mode 100644 modules/users/controllers/index.controller.ts create mode 100644 shared/utils/crypto.ts diff --git a/compose.yml b/compose.yml index d68432e..20afbb3 100644 --- a/compose.yml +++ b/compose.yml @@ -10,6 +10,7 @@ services: context: . ports: - "8080:8080" + env_file: .env environment: - NODE_ENV=development networks: @@ -47,7 +48,7 @@ services: environment: - REDIS_PASSWORD=my_secret_password command: > - sh -c "redis-server --requirepass ${REDIS_PASSWORD}" + sh -c "redis-server --requirepass $$REDIS_PASSWORD" networks: - dojoh-net diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts new file mode 100644 index 0000000..2619b0c --- /dev/null +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -0,0 +1,237 @@ +import crypto from "node:crypto"; + +import { OAuthProvider } from "@prisma/client"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +import env from "@/config/env"; +import prisma from "@/database/prisma/client"; +import redis from "@/database/redis/client"; + +import type { Session } from "../models/Session"; +import { DiscordUserSchema } from "../schemas/discord-user.schema"; +import { createTokens } from "../serivces/jwt.service"; + +const DiscordOauthController = { + callback: async (req: FastifyRequest, reply: FastifyReply) => { + const { code, state, error } = req.query as { + code: string; + state: string; + error?: string; + }; + + if (error) { + req.log.warn(`OAuth error: ${error}`); + return reply.status(400).send(`OAuth error: ${error}`); + } + + // @ts-expect-error + if (state !== req.state) { + req.log.warn("State mismatch. Possible CSRF attack"); + return reply.status(400).send("State mismatch. Possible CSRF attack"); + } + + const basicToken = Buffer.from( + `${env("DISCORD_CLIENT_ID")}:${env("DISCORD_CLIENT_SECRET")}`, + ).toString("base64"); + const urlencoded = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: env("DISCORD_REDIRECT_URI") as string, + }); + const tokenResponse = await fetch("https://discord.com/api/v10", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicToken}`, + }, + body: urlencoded.toString(), + }); + + if (!tokenResponse.ok) { + const content = await tokenResponse.text(); + req.log.warn(`Failed to obtain access token from Discord: ${content}`); + return reply + .status(400) + .send("Failed to obtain access token from Discord"); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + token_type: "Bearer"; + expires_in: number; + refresh_token: string; + scope: "identify"; + }; + + if (!tokens.access_token) { + req.log.warn("Failed to obtain access token from Discord"); + return reply + .status(400) + .send("Failed to obtain access token from Discord"); + } + + const userResponse = await fetch("https://discord.com/api/v10/users/@me", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }); + + if (!userResponse.ok) { + const content = await userResponse.text(); + req.log.warn(`Failed to obtain user info from Discord: ${content}`); + return reply.status(400).send("Failed to obtain user info from Discord"); + } + + const userData = await userResponse.json(); + const discordUser = DiscordUserSchema.parse(userData); + + if (!discordUser.id) { + req.log.warn("Google user ID not found"); + return reply.status(400).send("Google user ID not found"); + } + + const oauthAccount = await prisma.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.GOOGLE, + provider_user_id: discordUser.id, + }, + }, + update: { + provider_username: discordUser.username, + provider_avatar_url: discordUser.avatar + ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` + : null, + provider_metadata: { + email: discordUser.email, + lng: discordUser.locale, + discriminator: discordUser.discriminator, + }, + }, + create: { + provider: OAuthProvider.DISCORD, + user_id: null, // Will be linked to a user account later + provider_user_id: discordUser.id, + provider_username: discordUser.username, + provider_avatar_url: discordUser.avatar + ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` + : null, + provider_metadata: { + email: discordUser.email, + locale: discordUser.locale, + discriminator: discordUser.discriminator, + }, + }, + }); + + const user = await prisma.user.upsert({ + where: { + email: discordUser.email, + }, + update: { + // Do nothing, we don't want to overwrite existing user data + }, + create: { + email: discordUser.email || "", + nickname: discordUser.username, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + }, + }); + + // Not need to wait for this, if it fails we can just log it and move on + prisma.oAuthAccount + .update({ + where: { + id: oauthAccount.id, + }, + data: { + user_id: user.id, + }, + }) + .catch((e) => { + req.log.warn( + "Failed to link OAuth account to user: " + + JSON.stringify( + { + userId: user.id, + oauthAccountId: oauthAccount.id, + error: (e as Error).message, + }, + null, + 2, + ), + ); + }); + + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { + id: user.id, + email: user.email, + name: user.nickname, + nickname: user.nickname, + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.GOOGLE, + accessToken: tokens.access_token || undefined, + refreshToken: tokens.refresh_token || undefined, + }, + }; + + redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); + + return reply.send({ + message: req.t("Logged in with Google successfully!"), + tokens: jwtTokens, + data: user, + }); + }, + generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + const state = crypto.randomBytes(32).toString("hex"); + + const params = new URLSearchParams({ + response_type: "code", + client_id: env("DISCORD_CLIENT_ID") as string, + redirect_uri: env("DISCORD_REDIRECT_URI") as string, + scope: ["identify", "email"].join("%20"), + prompt: "consent", // Always ask user to select account + integration_type: "1", // USER_INSTALL + }); + + // @ts-expect-error + req.state = state; + + const url = new URL( + `/oauth2/authorize?${params.toString()}`, + "https://discord.com", + ); + + const authorizationUrl = url.toString(); + + return reply.redirect(authorizationUrl); + }, +} as const; + +export default DiscordOauthController; diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts new file mode 100644 index 0000000..1d23dfa --- /dev/null +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -0,0 +1,235 @@ +import crypto from "node:crypto"; + +import { OAuthProvider } from "@prisma/client"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +import env from "@/config/env"; +import prisma from "@/database/prisma/client"; +import redis from "@/database/redis/client"; +import { + generateCodeChallenge, + generateCodeVerifier, +} from "@/shared/utils/crypto"; + +import type { Session } from "../models/Session"; +import { GithubUserSchema } from "../schemas/github-user.schema"; +import { createTokens } from "../serivces/jwt.service"; + +const GithubOauthController = { + callback: async (req: FastifyRequest, reply: FastifyReply) => { + const { code, state } = req.query as { + code: string; + state: string; + }; + + // if (error) { + // req.log.warn("OAuth error: " + error); + // return reply.status(400).send("OAuth error: " + error); + // } + + // @ts-expect-error + if (state !== req.state) { + req.log.warn("State mismatch. Possible CSRF attack"); + return reply.status(400).send("State mismatch. Possible CSRF attack"); + } + + const tokenResponse = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: env("GITHUB_CLIENT_ID"), + client_secret: env("GITHUB_CLIENT_SECRET"), + code, + redirect_uri: env("GITHUB_REDIRECT_URI"), + }), + }, + ); + + if (!tokenResponse.ok) { + const content = await tokenResponse.text(); + req.log.warn(`Failed to obtain access token from GitHub: ${content}`); + return reply + .status(400) + .send("Failed to obtain access token from GitHub"); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + scope: string; + token_type: string; + }; + + if (!tokens.access_token) { + req.log.warn("Failed to obtain access token from GitHub"); + return reply + .status(400) + .send("Failed to obtain access token from GitHub"); + } + + const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + }, + }); + + if (!userResponse.ok) { + req.log.warn( + `Failed to fetch user info from GitHub: ${await userResponse.text()}`, + ); + return reply.status(400).send("Failed to fetch user info from GitHub"); + } + + const userData = await userResponse.json(); + const githubUser = GithubUserSchema.parse(userData); + + if (!githubUser.id) { + req.log.warn("Google user ID not found"); + return reply.status(400).send("Google user ID not found"); + } + + const oauthAccount = await prisma.oAuthAccount.upsert({ + where: { + provider_user_id_idx: { + provider: OAuthProvider.GOOGLE, + provider_user_id: githubUser.id, + }, + }, + update: { + provider_username: githubUser.name, + provider_avatar_url: githubUser.avatar_url || null, + provider_metadata: { + email: githubUser.email, + name: githubUser.name, + }, + }, + create: { + provider: OAuthProvider.GOOGLE, + user_id: null, // Will be linked to a user account later + provider_user_id: githubUser.id, + provider_username: githubUser.login, + provider_avatar_url: githubUser.avatar_url, + provider_metadata: { + email: githubUser.email, + name: githubUser.name, + }, + }, + }); + + const user = await prisma.user.upsert({ + where: { + email: githubUser.email, + }, + update: { + // Do nothing, we don't want to overwrite existing user data + }, + create: { + email: githubUser.email || "", + nickname: githubUser.login, + }, + select: { + id: true, + email: true, + nickname: true, + password: false, + }, + }); + + // Not need to wait for this, if it fails we can just log it and move on + prisma.oAuthAccount + .update({ + where: { + id: oauthAccount.id, + }, + data: { + user_id: user.id, + }, + }) + .catch((e) => { + req.log.warn( + "Failed to link OAuth account to user: " + + JSON.stringify( + { + userId: user.id, + oauthAccountId: oauthAccount.id, + error: (e as Error).message, + }, + null, + 2, + ), + ); + }); + + const { refreshId, ...jwtTokens } = createTokens( + { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + 1, + ); + + const session: Session = { + id: user.id, + email: user.email, + name: user.nickname, + nickname: user.nickname, + // Hardware related info + ipv4: req.ip as Session["ipv4"], + userAgent: req.headers["user-agent"] || "unknown", + // Token related info + v: 1, + refreshId, + provider: { + type: OAuthProvider.GITHUB, + accessToken: tokens.access_token || undefined, + }, + }; + + await redis.set( + `session:${user.id}`, + JSON.stringify(session), + "EX", + 60 * 60 * 24 * 7, // 7 days + ); + + return reply.send({ + message: req.t("Logged in with Github successfully!"), + tokens: jwtTokens, + data: user, + }); + }, + generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const state = crypto.randomBytes(32).toString("hex"); + + const params = new URLSearchParams({ + client_id: env("GITHUB_CLIENT_ID") as string, + redirect_uri: env("GITHUB_REDIRECT_URI") as string, + scope: ["user:email", "read:user"].join(" "), + state, + code_challenge_method: "S256", + code_challenge: challenge, + }); + + // @ts-expect-error + req.state = state; + + const url = new URL( + `/login/oauth/authorize?${params.toString()}`, + "https://github.com", + ); + + const authorizationUrl = url.toString(); + + return reply.redirect(authorizationUrl); + }, +}; + +export default GithubOauthController; diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index 9b4a8cc..6372672 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -25,7 +25,7 @@ const oauth2Client = new google.auth.OAuth2( env("GOOGLE_REDIRECT_URI") as string, ); -export const googleOauth = { +const GoogleOauthController = { callback: async (req: FastifyRequest, reply: FastifyReply) => { const { code, state, error } = req.query as { code: string; @@ -34,8 +34,8 @@ export const googleOauth = { }; if (error) { - req.log.warn("OAuth error: " + error); - return reply.status(400).send("OAuth error: " + error); + req.log.warn(`OAuth error: ${error}`); + return reply.status(400).send(`OAuth error: ${error}`); } // @ts-expect-error @@ -171,11 +171,7 @@ export const googleOauth = { }); }, generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { - // Access scopes for two non-Sign-In scopes: Read-only Drive activity and Google Calendar. - const scopes: Array = [ - // "https://www.googleapis.com/auth/drive.metadata.readonly", - // "https://www.googleapis.com/auth/calendar.readonly", - ]; + const scopes: Array = ["openid", "profile", "email"]; // Generate a secure random state value. const state = crypto.randomBytes(32).toString("hex"); @@ -200,3 +196,5 @@ export const googleOauth = { return reply.redirect(authorizationUrl); }, }; + +export default GoogleOauthController; diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index bd0e1b2..a731edc 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -1,6 +1,4 @@ -import crypto from "node:crypto"; - -import { OAuthProvider, type User } from "@prisma/client"; +import { OAuthProvider } from "@prisma/client"; import bcrypt from "bcrypt"; import type { FastifyReply, FastifyRequest } from "fastify"; import jwt from "jsonwebtoken"; @@ -45,7 +43,7 @@ export default { if (user) { throw new ConflictError( - req.t("Email or Nickename already in use", { + req.t("Email or Nickname already in use", { ns: "errors", }), ); @@ -140,6 +138,10 @@ export default { throw new UnauthorizedError(req.t("Invalid credentials")); } + if (!user.password) { + throw new UnauthorizedError(req.t("Invalid credentials")); + } + const authenticated = await bcrypt.compare( credentials.password, user.password, @@ -376,12 +378,15 @@ export default { throw new UnauthorizedError(req.t("Unauthorized")); } + // TODO: revoke thrid party sessions if the user logged in with OAuth provider (google, github, discord, etc) + const decoded = jwt.verify(token, jwtConfig.secret, { algorithms: [jwtConfig.algorithm], }) as jwt.JwtPayload; const { id: userId } = decoded.sub as unknown as JwtSubject; - if (!redis.exists(`session:${userId}`)) { + const sessionExists = await redis.exists(`session:${userId}`); + if (!sessionExists) { throw new NotFoundError(req.t("Session not found")); } const rawSession = await redis.get(`session:${userId}`); diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index bd4ad1e..df45c6b 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -1,8 +1,9 @@ import type { FastifyInstance } from "fastify"; -import AuthController from "@/modules/auth/controllers/index.controller"; - -import { googleOauth } from "../controllers/google-oauth.controller"; +import DiscordOauthController from "../controllers/discord-oauth.controller"; +import GithubOauthController from "../controllers/github-oauth.controller"; +import GoogleOauthController from "../controllers/google-oauth.controller"; +import AuthController from "../controllers/index.controller"; export default async function authRouter(f: FastifyInstance) { f.post("/signup", AuthController.signup); @@ -15,12 +16,12 @@ export default async function authRouter(f: FastifyInstance) { f.delete("/logout", AuthController.logout); - f.get("/url/google", googleOauth.generateUrl); - f.get("/callback/google", googleOauth.callback); + f.get("/url/google", GoogleOauthController.generateUrl); + f.get("/callback/google", GoogleOauthController.callback); - // f.get("/url/github", githubOauth.generateUrl); - // f.get("/callback/github", githubOauth.callback); + f.get("/url/github", GithubOauthController.generateUrl); + f.get("/callback/github", GithubOauthController.callback); - // f.get("/url/discord", discordOauth.generateUrl); - // f.get("/callback/discord", discordOauth.callback); + f.get("/url/discord", DiscordOauthController.generateUrl); + f.get("/callback/discord", DiscordOauthController.callback); } diff --git a/modules/auth/schemas/discord-user.schema.ts b/modules/auth/schemas/discord-user.schema.ts new file mode 100644 index 0000000..91fc464 --- /dev/null +++ b/modules/auth/schemas/discord-user.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const DiscordUserSchema = z.object({ + id: z.string(), + username: z.string(), + avatar: z.string().optional(), + email: z.email().optional(), + discriminator: z.string().optional(), + locale: z.string().optional(), +}); diff --git a/modules/auth/schemas/github-user.schema.ts b/modules/auth/schemas/github-user.schema.ts new file mode 100644 index 0000000..08dac7b --- /dev/null +++ b/modules/auth/schemas/github-user.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const GithubUserSchema = z.object({ + id: z.string(), + login: z.string(), + avatar_url: z.url().optional(), + email: z.email().optional(), + name: z.string().optional(), +}); diff --git a/modules/users/controllers/.gitkeep b/modules/users/controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/users/controllers/index.controller.ts b/modules/users/controllers/index.controller.ts deleted file mode 100644 index 210ba6b..0000000 --- a/modules/users/controllers/index.controller.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { FastifyReply, FastifyRequest } from "fastify"; - -import prisma from "@/database/prisma/client"; - -// export default { -// async uploadAvatar(req: FastifyRequest, reply: FastifyReply) { -// const userId = req.user.id; - -// if (!req.file) { -// return reply.status(400).send({ error: req.t("No file uploaded") }); -// } - -// const avatarUrl = `/uploads/${req.file.filename}`; - -// try { -// await prisma.image.create({ - -// }); -// } -// } as const; diff --git a/plugins/zod.ts b/plugins/zod.ts index 83faf4d..de5ad96 100644 --- a/plugins/zod.ts +++ b/plugins/zod.ts @@ -2,6 +2,6 @@ import fp from "fastify-plugin"; export default fp(async (f) => { f.addHook("preHandler", async (req) => { - const lng = req.query; + const _lng = req.query; }); }); diff --git a/shared/utils/crypto.ts b/shared/utils/crypto.ts new file mode 100644 index 0000000..3f4f51c --- /dev/null +++ b/shared/utils/crypto.ts @@ -0,0 +1,24 @@ +export const generateCodeVerifier = (length = 128) => { + const charset = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + let result = ""; + const randomValues = crypto.getRandomValues(new Uint8Array(length)); + + for (let i = 0; i < length; i++) { + result += charset[(randomValues[i] as number) % charset.length]; + } + + return result; +}; + +export const generateCodeChallenge = async (verifier: string) => { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + + const digest = await crypto.subtle.digest("SHA-256", data); + + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +}; From 0ef509b4244c60e33c12e861958660fc641ca72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 21:19:17 -0300 Subject: [PATCH 07/11] fix: biome check --- modules/auth/controllers/index.controller.ts | 2 +- modules/auth/serivces/jwt.service.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index a731edc..6f834df 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -134,7 +134,7 @@ export default { }, }); - if (!user || !user.password) { + if (!user?.password) { throw new UnauthorizedError(req.t("Invalid credentials")); } diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts index 8d7ecd4..c251344 100644 --- a/modules/auth/serivces/jwt.service.ts +++ b/modules/auth/serivces/jwt.service.ts @@ -9,7 +9,8 @@ const REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 10; export const createTokens = (sub: unknown, version: number = 1) => { const now = Math.floor(Date.now() / 1000); const accessExp = now + jwtConfig.access.expiresIn; - const refreshExp = now + jwtConfig.refresh.expiresIn + REFRESH_TOKEN_GRACE_PERIOD_SECONDS; + const refreshExp = + now + jwtConfig.refresh.expiresIn + REFRESH_TOKEN_GRACE_PERIOD_SECONDS; const jti = crypto.randomUUID(); const accessToken = jwt.sign( From 1a114b01fd072ff5e48cf356ba694fd3eaec0622 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 01:21:49 +0000 Subject: [PATCH 08/11] fix(github-oauth): fall back to nickname when GitHub email is absent Agent-Logs-Url: https://github.com/dojoh-dev/api/sessions/2b746871-9486-4932-89f8-036a9433bb65 Co-authored-by: itssimmons <62354548+itssimmons@users.noreply.github.com> --- modules/auth/controllers/github-oauth.controller.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index 1d23dfa..4ddbad8 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -122,14 +122,14 @@ const GithubOauthController = { }); const user = await prisma.user.upsert({ - where: { - email: githubUser.email, - }, + where: githubUser.email + ? { email: githubUser.email } + : { nickname: githubUser.login }, update: { // Do nothing, we don't want to overwrite existing user data }, create: { - email: githubUser.email || "", + email: githubUser.email ?? "", nickname: githubUser.login, }, select: { From bff4bc0cd10457528fb6c450c4aff750dbb7dee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 22:27:00 -0300 Subject: [PATCH 09/11] fix: github comments --- .env.example | 26 +++++++-- bun.lock | 3 ++ config/jwt.ts | 2 +- index.ts | 49 ++--------------- .../controllers/discord-oauth.controller.ts | 53 ++++++++++--------- .../controllers/github-oauth.controller.ts | 42 ++++++++++----- .../controllers/google-oauth.controller.ts | 21 +++++--- modules/auth/controllers/index.controller.ts | 15 +++--- modules/auth/routes/index.router.ts | 20 ++----- modules/auth/routes/oauth.router.ts | 16 ++++++ modules/auth/serivces/jwt.service.ts | 5 +- package.json | 1 + plugins/i18n.ts | 2 +- plugins/jwt.ts | 29 ++++------ plugins/zod.ts | 7 --- 15 files changed, 143 insertions(+), 148 deletions(-) create mode 100644 modules/auth/routes/oauth.router.ts delete mode 100644 plugins/zod.ts diff --git a/.env.example b/.env.example index 9bb2dca..dd872f4 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,28 @@ -NODE_ENV= +NODE_ENV=development -POSTGRES_USERNAME= +POSTGRES_USERNAME=postgres POSTGRES_DATABASE= POSTGRES_PASSWORD= POSTGRES_HOST= -POSTGRES_PORT= +POSTGRES_PORT=5432 + +DATABASE_URL=postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE} REDIS_HOST= -REDIS_PORT= -REDIS_USERNAME= +REDIS_PORT=6379 +REDIS_USERNAME=default REDIS_PASSWORD= + +COOKIE_SECRET= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_REDIRECT_URI=https://example.com/auth/google/callback + +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI=https://example.com/auth/github/callback diff --git a/bun.lock b/bun.lock index b739060..8890a4c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "dojoh-dev", "dependencies": { + "@fastify/cookie": "^11.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcrypt": "^6.0.0", @@ -98,6 +99,8 @@ "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + "@fastify/cookie": ["@fastify/cookie@11.0.2", "", { "dependencies": { "cookie": "^1.0.0", "fastify-plugin": "^5.0.0" } }, "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA=="], + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], diff --git a/config/jwt.ts b/config/jwt.ts index b4cfa46..a92a548 100644 --- a/config/jwt.ts +++ b/config/jwt.ts @@ -10,6 +10,6 @@ export default { }, refresh: { - expiresIn: 60 * 60, // 1 hour in seconds + expiresIn: 60 * 60 * 24, // 1 day in seconds }, } satisfies JwtConfig; diff --git a/index.ts b/index.ts index 10d797c..73bf513 100644 --- a/index.ts +++ b/index.ts @@ -1,62 +1,23 @@ +import fastifyCookie from "@fastify/cookie"; import Fastify from "fastify"; import env from "./config/env"; import i18nPlugin from "./plugins/i18n"; -import jwtPlugin from "./plugins/jwt"; const f = Fastify({ logger: true, }); -f.register(i18nPlugin); -f.register(jwtPlugin); - -f.get("/", async () => { - return { message: "Hello World!" }; +f.register(fastifyCookie, { + secret: env("COOKIE_SECRET", undefined), }); +f.register(i18nPlugin); + f.get("/health", async () => { return { status: "ok", timestamp: new Date().toISOString() }; }); -f.post("/echo", async (request) => { - console.debug("[BODY]: ", request.body); - console.debug("[QUERY]: ", request.query); - console.debug("[PARAMS]: ", request.params); - console.debug("[HEADERS]: ", request.headers); - // console.debug("[RAW]: ", request.raw); - // console.debug("[SERVER]: ", request.server); - console.debug("[ID]: ", request.id); - console.debug("[IP]: ", request.ip); - console.debug("[IPS]: ", request.ips); - console.debug("[HOST]: ", request.host); - console.debug("[HOSTNAME]: ", request.hostname); - console.debug("[PORT]: ", request.port); - console.debug("[PROTOCOL]: ", request.protocol); - console.debug("[URL]: ", request.url); - console.debug("[ROUTE_METHOD]: ", request.routeOptions.method); - console.debug( - "[ROUTE_BODY_LIMIT]: ", - request.routeOptions.bodyLimit - ? `${(request.routeOptions.bodyLimit / (1024 * 1024)).toFixed(2)} MB` - : "N/A", - ); - console.debug("[ROUTE_URL]: ", request.routeOptions.url); - console.debug( - "[ROUTE_ATTACH_VALIDATION]: ", - request.routeOptions.attachValidation, - ); - console.debug("[ROUTE_LOG_LEVEL]: ", request.routeOptions.logLevel); - console.debug("[ROUTE_VERSION]: ", request.routeOptions.version); - console.debug("[ROUTE_EXPOSE_HEAD]: ", request.routeOptions.exposeHeadRoute); - console.debug( - "[ROUTE_PREFIX_TRAILING_SLASH]: ", - request.routeOptions.prefixTrailingSlash, - ); - - return { received: request.body }; -}); - // Register route modules f.register(async (f) => { const { default: version_1 } = await import("./routes/v1"); diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts index 2619b0c..8a81681 100644 --- a/modules/auth/controllers/discord-oauth.controller.ts +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -13,19 +13,14 @@ import { createTokens } from "../serivces/jwt.service"; const DiscordOauthController = { callback: async (req: FastifyRequest, reply: FastifyReply) => { - const { code, state, error } = req.query as { + const { code, state } = req.query as { code: string; state: string; - error?: string; }; - if (error) { - req.log.warn(`OAuth error: ${error}`); - return reply.status(400).send(`OAuth error: ${error}`); - } + const storedState = req.cookies["oauth_state"]; - // @ts-expect-error - if (state !== req.state) { + if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); return reply.status(400).send("State mismatch. Possible CSRF attack"); } @@ -38,14 +33,17 @@ const DiscordOauthController = { code, redirect_uri: env("DISCORD_REDIRECT_URI") as string, }); - const tokenResponse = await fetch("https://discord.com/api/v10", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${basicToken}`, + const tokenResponse = await fetch( + "https://discord.com/api/v10/oauth2/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicToken}`, + }, + body: urlencoded.toString(), }, - body: urlencoded.toString(), - }); + ); if (!tokenResponse.ok) { const content = await tokenResponse.text(); @@ -86,14 +84,14 @@ const DiscordOauthController = { const discordUser = DiscordUserSchema.parse(userData); if (!discordUser.id) { - req.log.warn("Google user ID not found"); - return reply.status(400).send("Google user ID not found"); + req.log.warn("Discord user ID not found"); + return reply.status(400).send("Discord user ID not found"); } const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { - provider: OAuthProvider.GOOGLE, + provider: OAuthProvider.DISCORD, provider_user_id: discordUser.id, }, }, @@ -189,13 +187,13 @@ const DiscordOauthController = { v: 1, refreshId, provider: { - type: OAuthProvider.GOOGLE, + type: OAuthProvider.DISCORD, accessToken: tokens.access_token || undefined, refreshToken: tokens.refresh_token || undefined, }, }; - redis.set( + await redis.set( `session:${user.id}`, JSON.stringify(session), "EX", @@ -203,25 +201,30 @@ const DiscordOauthController = { ); return reply.send({ - message: req.t("Logged in with Google successfully!"), + message: req.t("Logged in with Discord successfully!"), tokens: jwtTokens, data: user, }); }, - generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + generateUrl: async (_: FastifyRequest, reply: FastifyReply) => { const state = crypto.randomBytes(32).toString("hex"); const params = new URLSearchParams({ response_type: "code", client_id: env("DISCORD_CLIENT_ID") as string, redirect_uri: env("DISCORD_REDIRECT_URI") as string, - scope: ["identify", "email"].join("%20"), + scope: ["identify", "email"].join(" "), prompt: "consent", // Always ask user to select account integration_type: "1", // USER_INSTALL + state, }); - // @ts-expect-error - req.state = state; + reply.setCookie("oauth_state", state, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 5, // 5m + }); const url = new URL( `/oauth2/authorize?${params.toString()}`, diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index 4ddbad8..afaad4e 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -22,13 +22,17 @@ const GithubOauthController = { state: string; }; - // if (error) { - // req.log.warn("OAuth error: " + error); - // return reply.status(400).send("OAuth error: " + error); - // } + const storedState = req.cookies["oauth_state"]; + const verifier = req.cookies["oauth_verifier"]; - // @ts-expect-error - if (state !== req.state) { + if (!storedState || !verifier) { + req.log.warn("Missing state or code verifier in cookies"); + return reply + .status(400) + .send("Missing state or code verifier in cookies"); + } + + if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); return reply.status(400).send("State mismatch. Possible CSRF attack"); } @@ -45,6 +49,7 @@ const GithubOauthController = { client_id: env("GITHUB_CLIENT_ID"), client_secret: env("GITHUB_CLIENT_SECRET"), code, + code_verifier: verifier, redirect_uri: env("GITHUB_REDIRECT_URI"), }), }, @@ -89,14 +94,14 @@ const GithubOauthController = { const githubUser = GithubUserSchema.parse(userData); if (!githubUser.id) { - req.log.warn("Google user ID not found"); - return reply.status(400).send("Google user ID not found"); + req.log.warn("Github user ID not found"); + return reply.status(400).send("Github user ID not found"); } const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { - provider: OAuthProvider.GOOGLE, + provider: OAuthProvider.GITHUB, provider_user_id: githubUser.id, }, }, @@ -109,7 +114,7 @@ const GithubOauthController = { }, }, create: { - provider: OAuthProvider.GOOGLE, + provider: OAuthProvider.GITHUB, user_id: null, // Will be linked to a user account later provider_user_id: githubUser.id, provider_username: githubUser.login, @@ -204,7 +209,7 @@ const GithubOauthController = { data: user, }); }, - generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + generateUrl: async (_: FastifyRequest, reply: FastifyReply) => { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = crypto.randomBytes(32).toString("hex"); @@ -218,8 +223,19 @@ const GithubOauthController = { code_challenge: challenge, }); - // @ts-expect-error - req.state = state; + reply.setCookie("oauth_state", state, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 5, // 5m + }); + + reply.setCookie("oauth_verifier", verifier, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 5, // 5m + }); const url = new URL( `/login/oauth/authorize?${params.toString()}`, diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index 6372672..f9fb80d 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -38,8 +38,9 @@ const GoogleOauthController = { return reply.status(400).send(`OAuth error: ${error}`); } - // @ts-expect-error - if (state !== req.state) { + const storedState = req.cookies["oauth_state"]; + + if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); return reply.status(400).send("State mismatch. Possible CSRF attack"); } @@ -60,6 +61,7 @@ const GoogleOauthController = { return reply.status(400).send("Google user ID not found"); } + const googleUsername = googleUser.email.split("@")[0] as string; const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { @@ -68,7 +70,7 @@ const GoogleOauthController = { }, }, update: { - provider_username: googleUser.name, + provider_username: googleUsername, provider_avatar_url: googleUser.picture, provider_metadata: { email: googleUser.email, @@ -81,6 +83,7 @@ const GoogleOauthController = { provider_username: googleUser.name, provider_avatar_url: googleUser.picture, provider_metadata: { + name: googleUser.name, email: googleUser.email, }, }, @@ -95,7 +98,7 @@ const GoogleOauthController = { }, create: { email: googleUser.email, - nickname: googleUser.name, + nickname: googleUsername, }, select: { id: true, @@ -157,7 +160,7 @@ const GoogleOauthController = { }, }; - redis.set( + await redis.set( `session:${user.id}`, JSON.stringify(session), "EX", @@ -177,8 +180,12 @@ const GoogleOauthController = { const state = crypto.randomBytes(32).toString("hex"); // Store state in the session - // @ts-expect-error - req.state = state; + reply.setCookie("oauth_state", state, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 5, // 5m + }); // Generate a url that asks permissions for the Drive activity and Google Calendar scope const authorizationUrl = oauth2Client.generateAuthUrl({ diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index 6f834df..40a0815 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -178,7 +178,7 @@ export default { }, }; - redis.set( + await redis.set( `session:${user.id}`, JSON.stringify(session), "EX", @@ -282,16 +282,13 @@ export default { throw new NotFoundError(req.t("Session not found")); } - const userSession = JSON.parse(rawUserSession) as { - refreshId: string; - version: number; - }; + const userSession = JSON.parse(rawUserSession) as Session; if (userSession.refreshId !== refreshId) { throw new UnauthorizedError(req.t("Invalid refresh token")); } - const newVersion = userSession.version + 1; + const newVersion = userSession.v + 1; const tokens = createTokens({ ...subject }, newVersion); @@ -397,7 +394,11 @@ export default { v: session.v + 1, }; - redis.set(`session:${userId}`, JSON.stringify(updatedSession), "KEEPTTL"); + await redis.set( + `session:${userId}`, + JSON.stringify(updatedSession), + "KEEPTTL", + ); return reply.status(204).send(); } catch (e) { diff --git a/modules/auth/routes/index.router.ts b/modules/auth/routes/index.router.ts index df45c6b..6f6338a 100644 --- a/modules/auth/routes/index.router.ts +++ b/modules/auth/routes/index.router.ts @@ -1,27 +1,17 @@ import type { FastifyInstance } from "fastify"; -import DiscordOauthController from "../controllers/discord-oauth.controller"; -import GithubOauthController from "../controllers/github-oauth.controller"; -import GoogleOauthController from "../controllers/google-oauth.controller"; +import jwtPlugin from "@/plugins/jwt"; + import AuthController from "../controllers/index.controller"; export default async function authRouter(f: FastifyInstance) { f.post("/signup", AuthController.signup); - f.post("/login", AuthController.login); - f.post("/refresh", AuthController.refresh); + await f.register(jwtPlugin); + // Protected routes + f.post("/refresh", AuthController.refresh); f.delete("/revoke", AuthController.revoke); - f.delete("/logout", AuthController.logout); - - f.get("/url/google", GoogleOauthController.generateUrl); - f.get("/callback/google", GoogleOauthController.callback); - - f.get("/url/github", GithubOauthController.generateUrl); - f.get("/callback/github", GithubOauthController.callback); - - f.get("/url/discord", DiscordOauthController.generateUrl); - f.get("/callback/discord", DiscordOauthController.callback); } diff --git a/modules/auth/routes/oauth.router.ts b/modules/auth/routes/oauth.router.ts new file mode 100644 index 0000000..863bc1e --- /dev/null +++ b/modules/auth/routes/oauth.router.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from "fastify"; + +import DiscordOauthController from "../controllers/discord-oauth.controller"; +import GithubOauthController from "../controllers/github-oauth.controller"; +import GoogleOauthController from "../controllers/google-oauth.controller"; + +export default async function oauthRouter(f: FastifyInstance) { + f.get("/url/google", GoogleOauthController.generateUrl); + f.get("/callback/google", GoogleOauthController.callback); + + f.get("/url/github", GithubOauthController.generateUrl); + f.get("/callback/github", GithubOauthController.callback); + + f.get("/url/discord", DiscordOauthController.generateUrl); + f.get("/callback/discord", DiscordOauthController.callback); +} diff --git a/modules/auth/serivces/jwt.service.ts b/modules/auth/serivces/jwt.service.ts index c251344..f422c96 100644 --- a/modules/auth/serivces/jwt.service.ts +++ b/modules/auth/serivces/jwt.service.ts @@ -4,13 +4,10 @@ import jwt from "jsonwebtoken"; import jwtConfig from "@/config/jwt"; -const REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 10; - export const createTokens = (sub: unknown, version: number = 1) => { const now = Math.floor(Date.now() / 1000); const accessExp = now + jwtConfig.access.expiresIn; - const refreshExp = - now + jwtConfig.refresh.expiresIn + REFRESH_TOKEN_GRACE_PERIOD_SECONDS; + const refreshExp = now + jwtConfig.refresh.expiresIn; const jti = crypto.randomUUID(); const accessToken = jwt.sign( diff --git a/package.json b/package.json index 92cd1fb..8201001 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "biome:lint": "biome lint" }, "dependencies": { + "@fastify/cookie": "^11.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcrypt": "^6.0.0", diff --git a/plugins/i18n.ts b/plugins/i18n.ts index ba634c2..525da34 100644 --- a/plugins/i18n.ts +++ b/plugins/i18n.ts @@ -18,7 +18,7 @@ await i18next }, }); -export default fp(async (f) => { +export default fp((f) => { // attach i18next middleware to raw req/res f.addHook("onRequest", (req, reply, done) => { middleware.handle(i18next)(req.raw, reply.raw, done); diff --git a/plugins/jwt.ts b/plugins/jwt.ts index 93eff85..9e2679a 100644 --- a/plugins/jwt.ts +++ b/plugins/jwt.ts @@ -6,30 +6,23 @@ import redis from "@/database/redis/client"; import type { Session } from "@/modules/auth/models/Session"; import type { JwtSubject } from "@/modules/auth/types/jwt"; -export default fp(async (f) => { +export default fp((f) => { f.decorateRequest("user", null); f.addHook("preHandler", async (req, reply) => { - const { pathname } = new URL(req.url, `http://${req.headers.host}`); - - // Skip authentication for public routes - if (publicRoutes.some((route) => pathname.startsWith(route))) { - return; - } - - const authHeader = req.headers.authorization; + try { + const authHeader = req.headers.authorization; - if (!authHeader) { - throw new jwt.JsonWebTokenError(req.t("Header missing")); - } + if (!authHeader) { + throw new jwt.JsonWebTokenError(req.t("Header missing")); + } - const token = authHeader.split(" ")[1]; + const token = authHeader.split(" ")[1]; - if (!token) { - throw new jwt.JsonWebTokenError(req.t("No token provided")); - } + if (!token) { + throw new jwt.JsonWebTokenError(req.t("No token provided")); + } - try { const decoded = jwt.verify(token, jwtConfig.secret, { algorithms: [jwtConfig.algorithm], }) as jwt.JwtPayload; @@ -62,5 +55,3 @@ export default fp(async (f) => { } }); }); - -const publicRoutes = ["/1/auth/signup", "/1/auth/login"]; diff --git a/plugins/zod.ts b/plugins/zod.ts deleted file mode 100644 index de5ad96..0000000 --- a/plugins/zod.ts +++ /dev/null @@ -1,7 +0,0 @@ -import fp from "fastify-plugin"; - -export default fp(async (f) => { - f.addHook("preHandler", async (req) => { - const _lng = req.query; - }); -}); From c8e67350279230b805d4182d918d3b542cd83c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Sat, 2 May 2026 22:28:02 -0300 Subject: [PATCH 10/11] fix: biome check --- modules/auth/controllers/discord-oauth.controller.ts | 2 +- modules/auth/controllers/github-oauth.controller.ts | 4 ++-- modules/auth/controllers/google-oauth.controller.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts index 8a81681..5b7316d 100644 --- a/modules/auth/controllers/discord-oauth.controller.ts +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -18,7 +18,7 @@ const DiscordOauthController = { state: string; }; - const storedState = req.cookies["oauth_state"]; + const storedState = req.cookies.oauth_state; if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index afaad4e..fe41ea9 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -22,8 +22,8 @@ const GithubOauthController = { state: string; }; - const storedState = req.cookies["oauth_state"]; - const verifier = req.cookies["oauth_verifier"]; + const storedState = req.cookies.oauth_state; + const verifier = req.cookies.oauth_verifier; if (!storedState || !verifier) { req.log.warn("Missing state or code verifier in cookies"); diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index f9fb80d..66fc349 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -38,7 +38,7 @@ const GoogleOauthController = { return reply.status(400).send(`OAuth error: ${error}`); } - const storedState = req.cookies["oauth_state"]; + const storedState = req.cookies.oauth_state; if (state !== storedState) { req.log.warn("State mismatch. Possible CSRF attack"); @@ -173,7 +173,7 @@ const GoogleOauthController = { data: user, }); }, - generateUrl: async (req: FastifyRequest, reply: FastifyReply) => { + generateUrl: async (_req: FastifyRequest, reply: FastifyReply) => { const scopes: Array = ["openid", "profile", "email"]; // Generate a secure random state value. From 79c368b2fd55d8125a0a83e78fe32a094a91efc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3n=20Villafa=C3=B1e?= Date: Mon, 4 May 2026 00:19:05 -0300 Subject: [PATCH 11/11] fix: some fixes --- modules/auth/controllers/discord-oauth.controller.ts | 5 ++++- modules/auth/controllers/github-oauth.controller.ts | 4 ++-- modules/auth/controllers/google-oauth.controller.ts | 7 ++++++- modules/auth/controllers/index.controller.ts | 1 + routes/v1.ts | 4 ++++ 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/modules/auth/controllers/discord-oauth.controller.ts b/modules/auth/controllers/discord-oauth.controller.ts index 5b7316d..2d2067b 100644 --- a/modules/auth/controllers/discord-oauth.controller.ts +++ b/modules/auth/controllers/discord-oauth.controller.ts @@ -88,6 +88,9 @@ const DiscordOauthController = { return reply.status(400).send("Discord user ID not found"); } + const discordEmail = + discordUser.email || `${discordUser.id}@discord.oauth.local`; + const oauthAccount = await prisma.oAuthAccount.upsert({ where: { provider_user_id_idx: { @@ -219,7 +222,7 @@ const DiscordOauthController = { state, }); - reply.setCookie("oauth_state", state, { + reply.setCookie("dojoh.oauth_state", state, { httpOnly: true, secure: true, sameSite: "lax", diff --git a/modules/auth/controllers/github-oauth.controller.ts b/modules/auth/controllers/github-oauth.controller.ts index fe41ea9..cb8c3d5 100644 --- a/modules/auth/controllers/github-oauth.controller.ts +++ b/modules/auth/controllers/github-oauth.controller.ts @@ -223,14 +223,14 @@ const GithubOauthController = { code_challenge: challenge, }); - reply.setCookie("oauth_state", state, { + reply.setCookie("dojoh.oauth_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 60 * 5, // 5m }); - reply.setCookie("oauth_verifier", verifier, { + reply.setCookie("dojoh.oauth_verifier", verifier, { httpOnly: true, secure: true, sameSite: "lax", diff --git a/modules/auth/controllers/google-oauth.controller.ts b/modules/auth/controllers/google-oauth.controller.ts index 66fc349..6faedf4 100644 --- a/modules/auth/controllers/google-oauth.controller.ts +++ b/modules/auth/controllers/google-oauth.controller.ts @@ -180,7 +180,7 @@ const GoogleOauthController = { const state = crypto.randomBytes(32).toString("hex"); // Store state in the session - reply.setCookie("oauth_state", state, { + reply.setCookie("dojoh.oauth_state", state, { httpOnly: true, secure: true, sameSite: "lax", @@ -202,6 +202,11 @@ const GoogleOauthController = { return reply.redirect(authorizationUrl); }, + oneTap: async (req: FastifyRequest, reply: FastifyReply) => { + const { credential } = req.body as { + credential: string; + }; + }, }; export default GoogleOauthController; diff --git a/modules/auth/controllers/index.controller.ts b/modules/auth/controllers/index.controller.ts index 40a0815..4a70fd3 100644 --- a/modules/auth/controllers/index.controller.ts +++ b/modules/auth/controllers/index.controller.ts @@ -391,6 +391,7 @@ export default { const updatedSession: Session = { ...session, + refreshId: crypto.randomUUID(), v: session.v + 1, }; diff --git a/routes/v1.ts b/routes/v1.ts index dce929d..547bd27 100644 --- a/routes/v1.ts +++ b/routes/v1.ts @@ -4,10 +4,14 @@ export default async function version_1(f: FastifyInstance) { const { default: authRouter } = await import( "@/modules/auth/routes/index.router" ); + const { default: oauthRouter } = await import( + "@/modules/auth/routes/oauth.router" + ); const { default: kumiteRouter } = await import( "@/modules/kumite/routes/index.router" ); f.register(authRouter, { prefix: "/auth" }); + f.register(oauthRouter, { prefix: "/oauth" }); f.register(kumiteRouter, { prefix: "/kumite" }); }