diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b91781..5066d896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ - Added same site property to the clear cookies function ([#218](https://github.com/chingu-x/chingu-dashboard-be/pull/218)) - Added routes for teams to create own tech stack categories([#208](https://github.com/chingu-x/chingu-dashboard-be/pull/208)) - Added unit tests for Features controller and services ([#220](https://github.com/chingu-x/chingu-dashboard-be/pull/220)) +- Add github oauth and e2e test ([#194](https://github.com/chingu-x/chingu-dashboard-be/pull/222)) - Added GET endpoint for solo project ([#223](https://github.com/chingu-x/chingu-dashboard-be/pull/223)) - Added units test for sprints ([#224](https://github.com/chingu-x/chingu-dashboard-be/pull/224)) diff --git a/README.md b/README.md index fffdc1e7..555a43f8 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ DISCORD_CLIENT_ID={check Important Resources page} DISCORD_CLIENT_SECRET={check Important Resources page} DISCORD_CALLBACK_URL=http://localhost:8000/api/v1/auth/discord/redirect +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL=http://localhost:8000/api/v1/auth/github/redirect + # .env.test DATABASE_URL={your test database connection string} NODE_ENV=test diff --git a/package.json b/package.json index 20a1d025..3f1c0a4d 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "node-mailjet": "^6.0.6", "passport": "^0.7.0", "passport-discord": "^0.1.4", + "passport-github2": "^0.1.12", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "reflect-metadata": "^0.2.2", diff --git a/prisma/seed/data/oauth-providers.ts b/prisma/seed/data/oauth-providers.ts index 8fcdebae..b67eb04a 100644 --- a/prisma/seed/data/oauth-providers.ts +++ b/prisma/seed/data/oauth-providers.ts @@ -2,4 +2,7 @@ export default [ { name: "discord", }, + { + name: "github", + }, ]; diff --git a/prisma/seed/oauth.ts b/prisma/seed/oauth.ts deleted file mode 100644 index e009d07e..00000000 --- a/prisma/seed/oauth.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { prisma } from "./prisma-client"; - -const populateOAuthProviders = async () => { - await prisma.oAuthProvider.createMany({ - data: [ - { - name: "discord", - }, - { - name: "github", - }, - ], - }); -}; - -const populateOAuthUserProfiles = async () => {}; - -export const populateOAuth = async () => { - await populateOAuthProviders(); - await populateOAuthUserProfiles(); -}; - -console.log("OAuth Providers and userProfile populated"); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 54f811b0..4137c28d 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -44,6 +44,7 @@ import { CustomRequest } from "@/global/types/CustomRequest"; import { Response } from "express"; import { DiscordAuthGuard } from "./guards/discord-auth.guard"; import { AppConfigService } from "@/config/app/appConfig.service"; +import { GithubAuthGuard } from "./guards/github-auth.guard"; @ApiTags("Auth") @Controller("auth") export class AuthController { @@ -336,6 +337,11 @@ export class AuthController { return; } + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: "Invalid code", + type: BadRequestErrorResponse, + }) @UseGuards(DiscordAuthGuard) @Public() @Get("/discord/redirect") @@ -347,4 +353,33 @@ export class AuthController { const FRONTEND_URL = this.appConfigService.FrontendUrl; res.redirect(`${FRONTEND_URL}`); } + + @ApiOperation({ + summary: "Github oauth", + description: + "This does not work on swagger. Open `{BaseURL}/api/v1/auth/github/login` in a browser to see the GitHub popup.", + }) + @UseGuards(GithubAuthGuard) + @Public() + @Get("/github/login") + handleGithubLogin() { + return; + } + + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: "Invalid code", + type: BadRequestErrorResponse, + }) + @UseGuards(GithubAuthGuard) + @Public() + @Get("/github/redirect") + async handleGithubRedirect( + @Request() req: CustomRequest, + @Res({ passthrough: true }) res: Response, + ) { + await this.authService.returnTokensOnLoginSuccess(req, res); + const FRONTEND_URL = this.appConfigService.FrontendUrl; + res.redirect(`${FRONTEND_URL}`); + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index aa20e118..c7170a8b 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,6 +1,6 @@ import { Module } from "@nestjs/common"; import { AuthService } from "./auth.service"; -import { UsersModule } from "@/users/users.module"; +import { UsersModule } from "../users/users.module"; import { AuthController } from "./auth.controller"; import { PassportModule } from "@nestjs/passport"; import { LocalStrategy } from "./strategies/local.strategy"; @@ -9,11 +9,13 @@ import { AtStrategy } from "./strategies/at.strategy"; import { RtStrategy } from "./strategies/rt.strategy"; import { DiscordStrategy } from "./strategies/discord.strategy"; import { DiscordAuthService } from "./discord-auth.service"; +import { GithubStrategy } from "./strategies/github.strategy"; +import { GithubAuthService } from "./github-auth.service"; import { EmailService } from "../utils/emails/email.service"; import { MailConfigModule } from "@/config/mail/mailConfig.module"; import { AppConfigModule } from "@/config/app/appConfig.module"; import { AuthConfigModule } from "@/config/auth/authConfig.module"; -import { OAuthConfigModule } from "@/config/Oauth/oauthConfig.module"; +import { OAuthConfigModule } from "../config/Oauth/oauthConfig.module"; import { AuthConfig } from "@/config/auth/auth.interface"; @Module({ @@ -43,6 +45,11 @@ import { AuthConfig } from "@/config/auth/auth.interface"; provide: "DISCORD_OAUTH", useClass: DiscordAuthService, }, + GithubStrategy, + { + provide: "GITHUB_OAUTH", + useClass: GithubAuthService, + }, ], controllers: [AuthController], exports: [AuthService], diff --git a/src/auth/discord-auth.service.ts b/src/auth/discord-auth.service.ts index 9ea4c25e..dc575e5b 100644 --- a/src/auth/discord-auth.service.ts +++ b/src/auth/discord-auth.service.ts @@ -7,8 +7,8 @@ import { IAuthProvider } from "@/global/interfaces/oauth.interface"; import { PrismaService } from "@/prisma/prisma.service"; import { DiscordUser } from "@/global/types/auth.types"; import { generatePasswordHash } from "@/global/auth/utils"; - import { OAuthConfig } from "@/config/Oauth/oauthConfig.interface"; + @Injectable() export class DiscordAuthService implements IAuthProvider { constructor( @@ -40,10 +40,12 @@ export class DiscordAuthService implements IAuthProvider { "[discord-auth.service]: Cannot get email from discord to create a new Chingu account", ); + const existingUser = await this.findUserByEmails([user.email]); + // check if email is in the database, add oauth profile to existing account, otherwise, create a new user account return this.prisma.user.upsert({ where: { - email: user.email, + id: existingUser.id, }, update: { emailVerified: true, @@ -78,4 +80,19 @@ export class DiscordAuthService implements IAuthProvider { }, }); } + + async findUserByEmails(emails): Promise { + // collect emails as strings + const emailStrings = emails.map((email) => + typeof email === "string" ? email : email.value, + ); + + return this.prisma.user.findFirst({ + where: { + email: { + in: emailStrings, + }, + }, + }); + } } diff --git a/src/auth/github-auth.service.ts b/src/auth/github-auth.service.ts new file mode 100644 index 00000000..b3e0aa24 --- /dev/null +++ b/src/auth/github-auth.service.ts @@ -0,0 +1,121 @@ +import { + Injectable, + Inject, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; +import { + AuthUserResult, + IAuthProvider, +} from "../global/interfaces/oauth.interface"; +import { PrismaService } from "../prisma/prisma.service"; +import { GithubUser } from "../global/types/auth.types"; +import { generatePasswordHash } from "../global/auth/utils"; +import { OAuthConfig } from "@/config/Oauth/oauthConfig.interface"; + +@Injectable() +export class GithubAuthService implements IAuthProvider { + constructor( + private prisma: PrismaService, + @Inject("OAuth-Config") private oAuthConfig: OAuthConfig, + ) {} + async validateUser(user: GithubUser) { + const userInDb = await this.prisma.findUserByOAuthId( + "github", + user.githubId, + ); + + if (userInDb) + return { + id: userInDb.userId, + email: user.email, + }; + return this.createUser(user); + } + + async createUser(user: GithubUser): Promise { + // generate a random password and not tell them so they can't login, but they will be able to reset password, + // and will be able to login with this in future, + // or maybe the app will prompt user the input the password, exact oauth flow is to be determined + + // this should not happen when "user:email" is in the scope + if (!user.email) + throw new InternalServerErrorException( + "[github-auth.service]: Cannot get email from github to create a new Chingu account", + ); + + const existingUser = await this.findUserByEmails([ + user.email, + ...(user.verifiedEmails || []), + ]); + + // check if email is in the database, add oauth profile to existing account, otherwise, create a new user account + let upsertResult; + try { + upsertResult = await this.prisma.user.upsert({ + where: { + id: existingUser.id, + }, + update: { + emailVerified: true, + oAuthProfiles: { + create: { + provider: { + connect: { + name: "github", + }, + }, + providerUserId: user.githubId, + providerUsername: user.username, + }, + }, + }, + create: { + email: user.email.value, + password: await generatePasswordHash(), + emailVerified: true, + avatar: user.avatar, + oAuthProfiles: { + create: { + provider: { + connect: { + name: "github", + }, + }, + providerUserId: user.githubId, + providerUsername: user.username, + }, + }, + }, + }); + } catch (e) { + if (e.code === "P2025") { + if (e.message.includes("OAuthProvider")) { + throw new NotFoundException( + "OAuth provider not found in the database", + ); + } + } + console.error("Unexpected error during upsert:", e); + throw e; + } + return upsertResult; + } + + async findUserByEmails( + emails: (string | { value: string })[], + ): Promise { + // collect emails as strings + const emailStrings = emails.map((email) => + typeof email === "string" ? email : email.value, + ); + + return this.prisma.user.findFirst({ + where: { + email: { + in: emailStrings, + }, + }, + }); + } +} diff --git a/src/auth/guards/discord-auth.guard.ts b/src/auth/guards/discord-auth.guard.ts index 2a6aa4f5..b2cccb90 100644 --- a/src/auth/guards/discord-auth.guard.ts +++ b/src/auth/guards/discord-auth.guard.ts @@ -1,4 +1,8 @@ -import { ExecutionContext, Injectable } from "@nestjs/common"; +import { + BadRequestException, + ExecutionContext, + Injectable, +} from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; @Injectable() @@ -9,9 +13,20 @@ export class DiscordAuthGuard extends AuthGuard("discord") { }); } async canActivate(context: ExecutionContext): Promise { - const activate = (await super.canActivate(context)) as boolean; + let activate; + try { + activate = (await super.canActivate(context)) as boolean; + } catch (e) { + if (e.code == "invalid_grant") { + throw new BadRequestException( + `Invalid code in redirect query param.`, + ); + } + throw e; + } const request = context.switchToHttp().getRequest(); await super.logIn(request); + return activate; } } diff --git a/src/auth/guards/github-auth.guard.ts b/src/auth/guards/github-auth.guard.ts new file mode 100644 index 00000000..2367b8fa --- /dev/null +++ b/src/auth/guards/github-auth.guard.ts @@ -0,0 +1,32 @@ +import { + BadRequestException, + ExecutionContext, + Injectable, +} from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class GithubAuthGuard extends AuthGuard("github") { + constructor() { + super({ + session: false, + }); + } + async canActivate(context: ExecutionContext): Promise { + let activate; + try { + activate = (await super.canActivate(context)) as boolean; + } catch (e) { + if (e.message.includes("Failed to obtain access token")) { + throw new BadRequestException( + `Failed to obtain access token. Possibly because of invalid redirect code.`, + ); + } + throw e; + } + const request = context.switchToHttp().getRequest(); + await super.logIn(request); + + return activate; + } +} diff --git a/src/auth/strategies/github.strategy.ts b/src/auth/strategies/github.strategy.ts new file mode 100644 index 00000000..33c45b62 --- /dev/null +++ b/src/auth/strategies/github.strategy.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { Strategy } from "passport-github2"; +import { GithubProfile } from "@/global/types/auth.types"; +import { IAuthProvider } from "../../global/interfaces/oauth.interface"; +import { OAuthConfig } from "../../config/Oauth/oauthConfig.interface"; +import { InternalServerErrorException } from "@nestjs/common"; + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy, "github") { + constructor( + @Inject("GITHUB_OAUTH") + private readonly githubAuthService: IAuthProvider, + @Inject("OAuth-Config") private oAuthConfig: OAuthConfig, + ) { + const { clientId, clientSecret, callbackUrl } = oAuthConfig.github; + super({ + clientID: clientId, + clientSecret: clientSecret, + callbackURL: callbackUrl, + scope: ["identify", "user:email"], + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: GithubProfile, + ): Promise { + const { username, id, photos, emails } = profile; + const avatar = photos && photos.length > 0 ? photos[0].value : null; + + if (!emails || emails.length === 0) { + throw new InternalServerErrorException( + "[github-auth.service]: Cannot get email from GitHub.", + ); + } + + return this.githubAuthService.validateUser({ + githubId: id, + username: username || "", + avatar, + email: emails[0], + }); + } +} diff --git a/src/config/Oauth/oauthConfig.interface.ts b/src/config/Oauth/oauthConfig.interface.ts index 179a71b8..164f5ef2 100644 --- a/src/config/Oauth/oauthConfig.interface.ts +++ b/src/config/Oauth/oauthConfig.interface.ts @@ -4,5 +4,10 @@ export interface OAuthConfig { clientSecret: string; callbackUrl: string; }; + github: { + clientId: string; + clientSecret: string; + callbackUrl: string; + }; // Add other OAuth providers as needed } diff --git a/src/config/Oauth/oauthConfig.module.ts b/src/config/Oauth/oauthConfig.module.ts index 39e80879..eddfe734 100644 --- a/src/config/Oauth/oauthConfig.module.ts +++ b/src/config/Oauth/oauthConfig.module.ts @@ -29,6 +29,17 @@ import { OAuthConfig } from "./oauthConfig.interface"; "DISCORD_CALLBACK_URL", ) as string, }, + github: { + clientId: configService.get( + "GITHUB_CLIENT_ID", + ) as string, + clientSecret: configService.get( + "GITHUB_CLIENT_SECRET", + ) as string, + callbackUrl: configService.get( + "GITHUB_CALLBACK_URL", + ) as string, + }, // Add other OAuth providers as needed }), inject: [ConfigService], diff --git a/src/config/Oauth/oauthConfig.schema.ts b/src/config/Oauth/oauthConfig.schema.ts index 40dcab5b..3ebb6c6d 100644 --- a/src/config/Oauth/oauthConfig.schema.ts +++ b/src/config/Oauth/oauthConfig.schema.ts @@ -4,5 +4,9 @@ export const oauthValidationSchema = Joi.object({ DISCORD_CLIENT_ID: Joi.string().required(), DISCORD_CLIENT_SECRET: Joi.string().required(), DISCORD_CALLBACK_URL: Joi.string().required(), + + GITHUB_CLIENT_ID: Joi.string().required(), + GITHUB_CLIENT_SECRET: Joi.string().required(), + GITHUB_CALLBACK_URL: Joi.string().required(), // Add other OAuth providers as needed }); diff --git a/src/global/interfaces/oauth.interface.ts b/src/global/interfaces/oauth.interface.ts index beaa6740..919a333d 100644 --- a/src/global/interfaces/oauth.interface.ts +++ b/src/global/interfaces/oauth.interface.ts @@ -1,8 +1,12 @@ -import { DiscordUser } from "../types/auth.types"; +import { DiscordUser, GithubUser, GithubEmail } from "../types/auth.types"; + +export interface AuthUserResult { + id: string; + email: string | GithubEmail | undefined; +} export interface IAuthProvider { - // TODO: Maybe change it to OAuthUser: DiscordUser | GithubUser etc - // Or change it to a more general type name - validateUser(user: DiscordUser): void; - createUser(user: DiscordUser): void; + validateUser(user: DiscordUser | GithubUser): Promise; + createUser(user: DiscordUser | GithubUser): Promise; + findUserByEmails(emails: string[]): Promise; } diff --git a/src/global/types/auth.types.ts b/src/global/types/auth.types.ts index 4aec6dc2..35946599 100644 --- a/src/global/types/auth.types.ts +++ b/src/global/types/auth.types.ts @@ -1,6 +1,26 @@ +import { Profile } from "passport-github2"; + +export interface GithubEmail { + value: string; + type?: string; + verified?: boolean; +} + +export interface GithubProfile extends Profile { + emails?: GithubEmail[]; +} + export type DiscordUser = { discordId: string; username: string; avatar?: string | null; email: string | undefined; }; + +export type GithubUser = { + githubId: string; + username: string; + avatar?: string | null; + email: GithubEmail | undefined; + verifiedEmails?: string[]; +}; diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts index da5a6fa7..45404d83 100644 --- a/test/auth.e2e-spec.ts +++ b/test/auth.e2e-spec.ts @@ -91,6 +91,10 @@ describe("AuthController e2e Tests", () => { clientID: "discord-client-id", clientSecret: "dicord-client-secret", }, + github: { + clientID: "github-client-id", + clientSecret: "github-client-secret", + }, } as unknown as OAuthConfig, }, ], @@ -686,4 +690,24 @@ describe("AuthController e2e Tests", () => { expect(res.headers.location).toMatch(re); }); }); + + describe("Initiate Github OAuth GET /auth/github/login", () => { + it("should redirect ", async () => { + const res = await request(app.getHttpServer()) + .get("/auth/github/login") + .expect(302); + + const clientId = Oauth.github.clientId; + const responseType = "code"; + const redirectUrl = + "http%3A%2F%2F127\\.0\\.0\\.1%3A[0-9]+%2Fapi%2Fv1%2Fauth%2Fgithub%2Fredirect"; + const scope = "identify%2Cuser%3Aemail"; + + const re = new RegExp( + String.raw`https:\/\/github\.com\/login\/oauth\/authorize\?response_type=${responseType}&redirect_uri=${redirectUrl}&scope=${scope}&client_id=${clientId}`, + ); + + expect(res.headers.location).toMatch(re); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 052a8418..3be5eaaa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1173,10 +1173,17 @@ resolved "https://registry.yarnpkg.com/@types/node-mailjet/-/node-mailjet-3.3.12.tgz#07dba18d9a0299ebd6319696da93afe1a0d0a573" integrity sha512-M9HRtJSB64wfV9ZFOBHBKT67cV+rCjo3Rbc9A4Fv/CxHcjcXnVMEh2vUpRcqzO6Tx0TEbDq8Xzt6009SkG5Z+w== +<<<<<<< HEAD +"@types/node@*", "@types/node@^20.3.1": + version "20.11.30" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.30.tgz#9c33467fc23167a347e73834f788f4b9f399d66f" + integrity sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw== +======= "@types/node@*", "@types/node@^22.10.2": version "22.10.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ== +>>>>>>> origin/dev dependencies: undici-types "~6.20.0" @@ -4798,6 +4805,13 @@ passport-discord@^0.1.4: dependencies: passport-oauth2 "^1.5.0" +passport-github2@^0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/passport-github2/-/passport-github2-0.1.12.tgz#a72ebff4fa52a35bc2c71122dcf470d1116f772c" + integrity sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw== + dependencies: + passport-oauth2 "1.x.x" + passport-jwt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" @@ -4813,7 +4827,7 @@ passport-local@^1.0.0: dependencies: passport-strategy "1.x.x" -passport-oauth2@^1.5.0: +passport-oauth2@1.x.x, passport-oauth2@^1.5.0: version "1.8.0" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==