Skip to content

Commit

Permalink
fix(auth): fix link function for GitHub
Browse files Browse the repository at this point in the history
  • Loading branch information
zz5840 committed Oct 6, 2023
1 parent f0360a3 commit c85eaad
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 88 deletions.
37 changes: 24 additions & 13 deletions backend/src/oauth/oauth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export class OAuthController {
}

@Get("available")
getAvailable(@Res({ passthrough: true }) response: Response, @Req() request: Request) {
return this.oauthService.getAvailable();
available(@Res({ passthrough: true }) response: Response, @Req() request: Request) {
return this.oauthService.available();
}

@Get("status")
Expand All @@ -30,30 +30,23 @@ export class OAuthController {
}

@Get("github")
github(@Res({ passthrough: true }) response: Response) {
github(@Res({ passthrough: true }) response: Response, @Query('link') link: boolean) {
const state = nanoid(10);
response.cookie("github_oauth_state", state, { sameSite: "strict" });
const url = "https://github.com/login/oauth/authorize?" + new URLSearchParams({
client_id: this.config.get("oauth.github-clientId"),
redirect_uri: this.config.get("general.appUrl") + "/api/oauth/github/callback",
redirect_uri: this.config.get("general.appUrl") + "/api/oauth/github/callback" + (link ? "/link" : ""),
state: state,
scope: "user:email",
scope: link ? "" : "user:email", // linking account doesn't need email
}).toString();
response.redirect(url);
// return `<script type='text/javascript'>location.href='${url}';</script>`;
}

@Get("github/callback")
async githubCallback(@Query() query: GithubDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) {
if (!this.config.get("oauth.github-enabled")) {
throw new NotFoundException();
}

const { state, code } = query;

if (state !== request.cookies.github_oauth_state) {
throw new BadRequestException("Invalid state");
}
this.oauthService.validate("github", request.cookies, state);

const token = await this.oauthService.github(code);
AuthController.addTokensToResponse(
Expand All @@ -64,6 +57,24 @@ export class OAuthController {
response.redirect(this.config.get("general.appUrl"));
}

@Get("github/callback/link")
@UseGuards(JwtGuard)
async githubLink(@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@Query() query: GithubDto,
@GetUser() user: User) {
const { state, code } = query;
this.oauthService.validate("github", request.cookies, state);

try {
await this.oauthService.githubLink(code, user);
response.redirect(this.config.get("general.appUrl") + '/account');
} catch (e) {
// TODO error page
throw e;
}
}

@Get("google")
@UseGuards(GoogleOAuthGuard)
async google() {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/oauth/oauth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { OAuthService } from './oauth.service';
import { AuthService } from "../auth/auth.service";
import { AuthModule } from "../auth/auth.module";
import { GoogleStrategy } from "./strategy/google.strategy";
import { OAuthRequestService } from "./oauthRequest.service";

@Module({
controllers: [OAuthController],
providers: [
OAuthService,
OAuthRequestService,
GoogleStrategy,
{
provide: "OAUTH_PLATFORMS",
Expand Down
123 changes: 52 additions & 71 deletions backend/src/oauth/oauth.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from "../prisma/prisma.service";
import { ConfigService } from "../config/config.service";
import { AuthService } from "../auth/auth.service";
import { User } from "@prisma/client";
import { nanoid } from "nanoid";
import fetch from "node-fetch";
import { OAuthRequestService } from "./oauthRequest.service";


@Injectable()
Expand All @@ -13,51 +13,39 @@ export class OAuthService {
private prisma: PrismaService,
private config: ConfigService,
private auth: AuthService,
private request: OAuthRequestService,
@Inject("OAUTH_PLATFORMS") private platforms: string[],
) {
}

getAvailable(): string[] {
validate(provider: string, cookies: Record<string, string>, state: string) {
if (!this.config.get(`oauth.${provider}-enabled`)) {
throw new NotFoundException();
}

if (cookies[`${provider}_oauth_state`] !== state) {
throw new BadRequestException("Invalid state");
}
}

available(): string[] {
return this.platforms
.map(platform => [platform, this.config.get(`oauth.${platform}-enabled`)])
.filter(([_, enabled]) => enabled)
.map(([platform, _]) => platform);
}

private async getGitHubToken(code: string): Promise<GitHubToken> {
const qs = new URLSearchParams();
qs.append("client_id", this.config.get("oauth.github-clientId"));
qs.append("client_secret", this.config.get("oauth.github-clientSecret"));
qs.append("code", code);

const res = await fetch("https://github.com/login/oauth/access_token?" + qs.toString(), {
method: "post",
headers: {
"Accept": "application/json",
}
});
return await res.json() as GitHubToken;
}

private async getGitHubUser(token: GitHubToken): Promise<GitHubUser> {
const res = await fetch("https://api.github.com/user", {
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `${token.token_type} ${token.access_token}`,
}
});
return await res.json() as GitHubUser;
}

private async getGitHubEmails(token: GitHubToken): Promise<string | undefined> {
const res = await fetch("https://api.github.com/user/public_emails", {
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `${token.token_type} ${token.access_token}`,
}
async status(user: User) {
const oauthUsers = await this.prisma.oAuthUser.findMany({
select: {
provider: true,
providerUsername: true,
},
where: {
userId: user.id,
},
});
const emails = await res.json() as GitHubEmail[];
return emails.find(e => e.primary && e.verified)?.email;
return Object.fromEntries(oauthUsers.map(u => [u.provider, u]));
}

async signIn(user: OAuthSignInDto) {
Expand Down Expand Up @@ -124,13 +112,34 @@ export class OAuthService {
return result;
}

async link(userId: string, provider: string, providerUserId: string, providerUsername: string) {
const oauthUser = await this.prisma.oAuthUser.findFirst({
where: {
provider,
providerUserId,
}
});
if (oauthUser) {
throw new BadRequestException(`This ${provider} account has been linked to another account`);
}

await this.prisma.oAuthUser.create({
data: {
userId,
provider,
providerUsername,
providerUserId,
}
});
}

async github(code: string) {
const ghToken = await this.getGitHubToken(code);
const ghUser = await this.getGitHubUser(ghToken);
const ghToken = await this.request.getGitHubToken(code);
const ghUser = await this.request.getGitHubUser(ghToken);
if (!ghToken.scope.includes("user:email")) {
throw new BadRequestException("No email permission granted");
}
const email = await this.getGitHubEmails(ghToken);
const email = await this.request.getGitHubEmail(ghToken);
return this.signIn({
provider: "github",
providerId: ghUser.id.toString(),
Expand All @@ -139,37 +148,9 @@ export class OAuthService {
});
}

async status(user: User) {
const oauthUsers = await this.prisma.oAuthUser.findMany({
select: {
provider: true,
providerUsername: true,
},
where: {
userId: user.id,
},
});
return Object.fromEntries(oauthUsers.map(u => [u.provider, u]));
async githubLink(code: string, user: User) {
const ghToken = await this.request.getGitHubToken(code);
const ghUser = await this.request.getGitHubUser(ghToken);
await this.link(user.id, 'github', ghUser.id.toString(), ghUser.name);
}
}


interface GitHubToken {
access_token: string;
token_type: string;
scope: string;
}

interface GitHubUser {
login: string;
id: number;
name?: string;
email?: string; // this filed seems only return null
}

interface GitHubEmail {
email: string;
primary: boolean,
verified: boolean,
visibility: string | null
}
66 changes: 66 additions & 0 deletions backend/src/oauth/oauthRequest.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import fetch from "node-fetch";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "../config/config.service";

@Injectable()
export class OAuthRequestService {
constructor(private config: ConfigService) {
}

async getGitHubToken(code: string): Promise<GitHubToken> {
const qs = new URLSearchParams();
qs.append("client_id", this.config.get("oauth.github-clientId"));
qs.append("client_secret", this.config.get("oauth.github-clientSecret"));
qs.append("code", code);

const res = await fetch("https://github.com/login/oauth/access_token?" + qs.toString(), {
method: "post",
headers: {
"Accept": "application/json",
}
});
return await res.json() as GitHubToken;
}

async getGitHubUser(token: GitHubToken): Promise<GitHubUser> {
const res = await fetch("https://api.github.com/user", {
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `${token.token_type} ${token.access_token}`,
}
});
return await res.json() as GitHubUser;
}

async getGitHubEmail(token: GitHubToken): Promise<string | undefined> {
const res = await fetch("https://api.github.com/user/public_emails", {
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `${token.token_type} ${token.access_token}`,
}
});
const emails = await res.json() as GitHubEmail[];
return emails.find(e => e.primary && e.verified)?.email;
}
}


interface GitHubToken {
access_token: string;
token_type: string;
scope: string;
}

interface GitHubUser {
login: string;
id: number;
name?: string;
email?: string; // this filed seems only return null
}

interface GitHubEmail {
email: string;
primary: boolean,
verified: boolean,
visibility: string | null
}
3 changes: 1 addition & 2 deletions frontend/src/pages/account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,7 @@ const Account = () => {
}</Button>
: <Button
component="a"
target="_blank"
href={getOAuthUrl(config.get('general.appUrl'), provider)}
href={getOAuthUrl(config.get('general.appUrl'), provider) + '?link=true'}
>{t('account.card.oauth.link')}</Button>
}
</Group>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/utils/oauth.util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { SiGithub, SiGoogle, SiOpenid } from "react-icons/si";
import React from "react";
import toast from "./toast.util";

const getOAuthUrl = (appUrl: string, provider: string, isRevoke = false) => {
return appUrl + '/api/oauth/' + provider + (isRevoke ? '/revoke' : '');
const getOAuthUrl = (appUrl: string, provider: string) => {
return `${appUrl}/api/oauth/${provider}`;
}

const getOAuthIcon = (provider: string) => {
Expand Down

0 comments on commit c85eaad

Please sign in to comment.