Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
dd31b38
Add github env vars to readme
curtwl Nov 12, 2024
db2d22c
Add github oauth passport dependencies
curtwl Nov 12, 2024
9135941
Add GithubUser type
curtwl Nov 12, 2024
f033dac
Add github-auth-service
curtwl Nov 12, 2024
1cb4252
Add GithubUser to oauth interface
curtwl Nov 12, 2024
7e4dfc3
Add Github oauth strategy
curtwl Nov 12, 2024
0559506
Update auth module for github
curtwl Nov 12, 2024
e394036
Add github auth guard
curtwl Nov 12, 2024
a9266f1
Update auth controller for github
curtwl Nov 12, 2024
2949359
Update oauthConfig interface for github
curtwl Nov 12, 2024
044717d
Update oauthConfig module for github
curtwl Nov 12, 2024
8b4461b
Update oauthConfig schema for github
curtwl Nov 12, 2024
e24869b
Add test for github oauth redirect
curtwl Nov 12, 2024
b12e197
Update changelog
curtwl Nov 12, 2024
c91a3ef
Remove console.log
curtwl Nov 12, 2024
2bc151d
Add Github provider to seed
curtwl Nov 16, 2024
5d109bd
Add github provider to oauth interface
curtwl Nov 16, 2024
13dc3d3
Make error handling more robust
curtwl Nov 16, 2024
ce21985
Remove console logs
curtwl Nov 16, 2024
b209387
Merge branch 'dev' into feature/oauth-github
cherylli Nov 17, 2024
268dd76
Catch invalid code errors in discord auth guard
curtwl Nov 24, 2024
fbca3fc
Add discord redirect invalid code ApiResponse to auth controller
curtwl Nov 24, 2024
889aa95
Catch invalid code errors in github auth guard
curtwl Nov 24, 2024
0b9dca2
Add github redirect invalid code ApiResponse to auth controller
curtwl Nov 24, 2024
d98cd9a
Fix bug in placement of console.error and throw e
curtwl Nov 24, 2024
8a414ff
Handle null emails
curtwl Nov 24, 2024
aafd1ef
merge from dev
curtwl Nov 24, 2024
c88cc69
Merge branch 'feature/oauth-github' of https://github.com/chingu-x/ch…
curtwl Nov 24, 2024
ee0f8fa
Add github oauth provder to beforeAll in auth tests
curtwl Nov 24, 2024
f9f8b4b
Catch invalid code for discord with e.code
curtwl Nov 25, 2024
4ff3e4c
Search all user emails for oauth
curtwl Jan 15, 2025
36d5945
Search all user emails for oauth
curtwl Jan 15, 2025
2977e16
Update some types and syntax for searching all emails
curtwl Jan 15, 2025
dafdd20
Add GithubProfile and GithubEmail types
curtwl Jan 15, 2025
13a4dc8
Update with new types
curtwl Jan 15, 2025
6f25ab0
Merge from dev
curtwl Jan 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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))

### Changed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.11",
"@prisma/client": "^5.16.1",
"@types/passport-github2": "^1.2.9",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
Expand All @@ -64,6 +65,7 @@
"node-mailjet": "^6.0.4",
"passport": "^0.6.0",
"passport-discord": "^0.1.4",
"passport-github2": "^0.1.12",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.13",
Expand Down
3 changes: 3 additions & 0 deletions prisma/seed/data/oauth-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@ export default [
{
name: "discord",
},
{
name: "github",
},
];
23 changes: 0 additions & 23 deletions prisma/seed/oauth.ts

This file was deleted.

35 changes: 35 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -336,6 +337,11 @@ export class AuthController {
return;
}

@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: "Invalid code",
type: BadRequestErrorResponse,
})
@UseGuards(DiscordAuthGuard)
@Public()
@Get("/discord/redirect")
Expand All @@ -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}`);
}
}
11 changes: 9 additions & 2 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({
Expand Down Expand Up @@ -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],
Expand Down
99 changes: 99 additions & 0 deletions src/auth/github-auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a tiny suggestion to make the intelliSense clearer, since Typescript was inferring unnecessarily a union type of objects with the same properties

Suggested change
async validateUser(user: GithubUser) {
async validateUser(user: GithubUser): Promise<AuthUserResult> {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is AuthUserResult something built in or something we have to define?
I think this might be same as the prisma User model type

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<AuthUserResult> {
// 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",
);

// 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: {
email: user.email,
},
update: {
emailVerified: true,
oAuthProfiles: {
create: {
provider: {
connect: {
name: "github",
},
},
providerUserId: user.githubId,
providerUsername: user.username,
},
},
},
create: {
email: user.email,
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;
}
}
19 changes: 17 additions & 2 deletions src/auth/guards/discord-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import {
BadRequestException,
ExecutionContext,
Injectable,
} from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
Expand All @@ -9,9 +13,20 @@ export class DiscordAuthGuard extends AuthGuard("discord") {
});
}
async canActivate(context: ExecutionContext): Promise<boolean> {
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;
}
Comment on lines 15 to +26

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For readability, could we move line 27 and 28 into the try block?

Suggested change
async canActivate(context: ExecutionContext): Promise<boolean> {
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;
}
try {
const activate = await super.canActivate(context) as boolean
const request = context.switchToHttp().getRequest();
await super.logIn(request);
return activate;
} 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;
}
}
32 changes: 32 additions & 0 deletions src/auth/guards/github-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
Comment on lines +16 to +31

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment here that I made in the file src/auth/guards/discord-auth.guard.ts

}
47 changes: 47 additions & 0 deletions src/auth/strategies/github.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Inject, Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy, Profile } from "passport-github2";
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,
Comment on lines +26 to +27

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like we're using these variables?

profile: Profile,
): Promise<any> {
const { username, id, photos, emails } = profile;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm getting an error saying that "'username, id, photos' properties does not exist on type GitHubProfile". Also I found a types package for passport-github2 at @types/passport-github2 on yarn.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the types package got lost resolving a merge conflict, oops! Definitely had that installed while developing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I'll look at again once you install the types package.


const avatar = photos && photos.length > 0 ? photos[0].value : null;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be related to the comment above about the types packages - for readability I think we should explicitly declare what the type of avatar is

const email = emails && emails.length > 0 ? emails[0].value : null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should match/check all the (github verified) emails with existing users in the database, instead of just the first email.
If none of them is found in the database we can create a new account with the primary email

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does discord need this functionality too?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably, but I don't think discord lets you have multiple emails


if (!email) {
throw new InternalServerErrorException(
"[github-auth.service]: Cannot get email from GitHub.",
);
}

return this.githubAuthService.validateUser({
githubId: id,
username: username ? username : "",
avatar,
email: email ? email : "",
});
}
}
5 changes: 5 additions & 0 deletions src/config/Oauth/oauthConfig.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@ export interface OAuthConfig {
clientSecret: string;
callbackUrl: string;
};
github: {
clientId: string;
clientSecret: string;
callbackUrl: string;
};
// Add other OAuth providers as needed
}
11 changes: 11 additions & 0 deletions src/config/Oauth/oauthConfig.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ import { OAuthConfig } from "./oauthConfig.interface";
"DISCORD_CALLBACK_URL",
) as string,
},
github: {
clientId: configService.get<string>(
"GITHUB_CLIENT_ID",
) as string,
clientSecret: configService.get<string>(
"GITHUB_CLIENT_SECRET",
) as string,
callbackUrl: configService.get<string>(
"GITHUB_CALLBACK_URL",
) as string,
},
// Add other OAuth providers as needed
}),
inject: [ConfigService],
Expand Down
Loading
Loading