From 419b5b0ae431a7825a5efecc73b78998c03835ec Mon Sep 17 00:00:00 2001 From: Rafi Arrafif Date: Tue, 5 Aug 2025 17:11:36 +0700 Subject: [PATCH] :sparkles: create oauth login Create authentication with oAuth using a third-party vendor. Currently, only GitHub is available, but more will be added in the future. --- .env.example | 10 +++++-- bun.lock | 30 ++++++++++++++++++- package.json | 1 + src/index.ts | 2 +- .../controllers/githubCallback.controller.ts | 20 +++++++++++++ .../controllers/githubRequest.controller.ts | 13 ++++++++ src/modules/auth/index.ts | 7 +++++ src/modules/auth/providers/github.provider.ts | 10 +++++++ .../auth/services/githubCallback.service.ts | 27 +++++++++++++++++ .../auth/services/githubRequest.service.ts | 16 ++++++++++ 10 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/modules/auth/controllers/githubCallback.controller.ts create mode 100644 src/modules/auth/controllers/githubRequest.controller.ts create mode 100644 src/modules/auth/index.ts create mode 100644 src/modules/auth/providers/github.provider.ts create mode 100644 src/modules/auth/services/githubCallback.service.ts create mode 100644 src/modules/auth/services/githubRequest.service.ts diff --git a/.env.example b/.env.example index d780aba..d77f9de 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ APP_NAME=NounozCommunity APP_ENV=development -PORT=3200 +APP_DOMAIN= +APP_PROTOCOL= +APP_PORT= API_KEY=nahidaa ALLOWED_ORIGINS=www.nounoz.com,nounoz.com,localhost @@ -28,4 +30,8 @@ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= -DATABASE_URL= \ No newline at end of file +DATABASE_URL= + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CLIENT_CALLBACK= \ No newline at end of file diff --git a/bun.lock b/bun.lock index f063230..441ab2e 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,9 @@ "@types/bcrypt": "^5.0.2", "@types/jsonwebtoken": "^9.0.9", "@types/mime-types": "^3.0.1", + "arctic": "^3.7.0", "aws-sdk": "^2.1692.0", + "axios": "^1.11.0", "bcrypt": "^5.1.1", "cookie": "^1.0.2", "elysia": "latest", @@ -97,6 +99,16 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], + + "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], + + "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], + "@prisma/client": ["@prisma/client@6.10.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w=="], "@prisma/config": ["@prisma/config@6.10.1", "", { "dependencies": { "jiti": "2.4.2" } }, "sha512-kz4/bnqrOrzWo8KzYguN0cden4CzLJJ+2VSpKtF8utHS3l1JS0Lhv6BLwpOX6X9yNreTbZQZwewb+/BMPDCIYQ=="], @@ -183,6 +195,8 @@ "aproba": ["aproba@2.0.0", "", {}, "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ=="], + "arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="], + "are-we-there-yet": ["are-we-there-yet@2.0.0", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -197,6 +211,8 @@ "aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="], + "axios": ["axios@1.11.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -393,9 +409,11 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - "form-data": ["form-data@4.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA=="], + "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], "fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -691,6 +709,8 @@ "propagate": ["propagate@2.0.1", "", {}, "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="], "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], @@ -891,6 +911,10 @@ "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + + "@types/node-fetch/form-data": ["form-data@4.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -949,6 +973,8 @@ "@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@types/node-fetch/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="], @@ -1017,6 +1043,8 @@ "read-pkg-up/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "@types/node-fetch/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="], "cz-conventional-changelog/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], diff --git a/package.json b/package.json index e1a6152..34c67cc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/bcrypt": "^5.0.2", "@types/jsonwebtoken": "^9.0.9", "@types/mime-types": "^3.0.1", + "arctic": "^3.7.0", "aws-sdk": "^2.1692.0", "bcrypt": "^5.1.1", "cookie": "^1.0.2", diff --git a/src/index.ts b/src/index.ts index 22b5edc..af4aec5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ validateEnv(); const { Elysia } = await import("elysia"); const { routes } = await import("./routes"); -const app = new Elysia().use(routes).listen(process.env.PORT || 3000); +const app = new Elysia().use(routes).listen(process.env.APP_PORT || 3000); console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` diff --git a/src/modules/auth/controllers/githubCallback.controller.ts b/src/modules/auth/controllers/githubCallback.controller.ts new file mode 100644 index 0000000..5dd524d --- /dev/null +++ b/src/modules/auth/controllers/githubCallback.controller.ts @@ -0,0 +1,20 @@ +import { Context } from "elysia"; +import { returnWriteResponse } from "../../../helpers/callback/httpResponse"; +import { githubCallbackService } from "../services/githubCallback.service"; +import { mainErrorHandler } from "../../../helpers/error/handler"; + +export const githubCallbackController = async ( + ctx: Context & { query: { code: string } } +) => { + try { + const userData = await githubCallbackService(ctx.query.code); + return returnWriteResponse( + ctx.set, + 200, + "Authenticated successfully!", + userData + ); + } catch (error) { + return mainErrorHandler(ctx.set, error); + } +}; diff --git a/src/modules/auth/controllers/githubRequest.controller.ts b/src/modules/auth/controllers/githubRequest.controller.ts new file mode 100644 index 0000000..03ea895 --- /dev/null +++ b/src/modules/auth/controllers/githubRequest.controller.ts @@ -0,0 +1,13 @@ +import { Context } from "elysia"; +import { returnReadResponse } from "../../../helpers/callback/httpResponse"; +import { githubRequestService } from "../services/githubRequest.service"; + +export const githubRequestController = async (ctx: Context) => { + const loginUrl = await githubRequestService(); + return returnReadResponse( + ctx.set, + 200, + "Login URL generated successfully", + String(loginUrl) + ); +}; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts new file mode 100644 index 0000000..1da74e4 --- /dev/null +++ b/src/modules/auth/index.ts @@ -0,0 +1,7 @@ +import Elysia from "elysia"; +import { githubRequestController } from "./controllers/githubRequest.controller"; +import { githubCallbackController } from "./controllers/githubCallback.controller"; + +export const authModule = new Elysia({ prefix: "/auth" }) + .get("/github", githubRequestController) + .get("/github/callback", githubCallbackController); diff --git a/src/modules/auth/providers/github.provider.ts b/src/modules/auth/providers/github.provider.ts new file mode 100644 index 0000000..c231ac8 --- /dev/null +++ b/src/modules/auth/providers/github.provider.ts @@ -0,0 +1,10 @@ +import { GitHub } from "arctic"; + +export const githubProvider = () => { + const redirectURI = `${process.env.APP_PROTOCOL}://${process.env.APP_DOMAIN}${process.env.GITHUB_CLIENT_CALLBACK}`; + return new GitHub( + process.env.GITHUB_CLIENT_ID!, + process.env.GITHUB_CLIENT_SECRET!, + redirectURI + ); +}; diff --git a/src/modules/auth/services/githubCallback.service.ts b/src/modules/auth/services/githubCallback.service.ts new file mode 100644 index 0000000..dae5587 --- /dev/null +++ b/src/modules/auth/services/githubCallback.service.ts @@ -0,0 +1,27 @@ +import { AppError } from "../../../helpers/error/instances/app"; +import { githubProvider } from "../providers/github.provider"; + +export const githubCallbackService = async (code: string) => { + try { + const github = githubProvider(); + const tokens = await github.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const userdata = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + const useremail = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + return { + userdata: await userdata.json(), + useremail: await useremail.json(), + }; + } catch (error) { + return new AppError(500, "Authentication service error", error); + } +}; diff --git a/src/modules/auth/services/githubRequest.service.ts b/src/modules/auth/services/githubRequest.service.ts new file mode 100644 index 0000000..a16c365 --- /dev/null +++ b/src/modules/auth/services/githubRequest.service.ts @@ -0,0 +1,16 @@ +import * as arctic from "arctic"; +import { githubProvider } from "../providers/github.provider"; +import { AppError } from "../../../helpers/error/instances/app"; + +export const githubRequestService = async () => { + try { + const github = githubProvider(); + const state = arctic.generateState(); + const scopes = ["user:email"]; + const url = github.createAuthorizationURL(state, scopes); + + return url; + } catch (error) { + throw new AppError(500, "Oops! something happening", error); + } +};