Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update #843 - Adds backend for Discord integration #960

Merged
merged 32 commits into from
Aug 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5d5b096
adds utility functions and updates schema for User
kondanna Jun 9, 2021
6d6fbd7
Merge branch 'master' of https://github.com/garageScript/c0d3-app int…
kondanna Jun 9, 2021
5353a22
backend for integration with Discord
kondanna Jun 11, 2021
a15ce82
Merge branch 'master' of https://github.com/garageScript/c0d3-app int…
kondanna Jun 11, 2021
37fd79c
removes refresh token from userInfo query
kondanna Jun 11, 2021
72d1ece
replaces qs with URLSearchParams
kondanna Jun 11, 2021
c1b2b28
adds return types to discord Auth functions
kondanna Jun 11, 2021
2da3c5a
excludes discordAuth.ts from test coverage
kondanna Jun 12, 2021
8a8e5f1
set base discordAPI url
kondanna Jun 12, 2021
9bf89b7
Merge branch 'master' of https://github.com/garageScript/c0d3-app int…
kondanna Jun 15, 2021
75a3de3
Merge branch 'master' of https://github.com/garageScript/c0d3-app int…
kondanna Jun 20, 2021
0ce8003
adds discord user id to userInfo return
kondanna Jun 20, 2021
a6a98b1
Merge branch 'master' of https://github.com/garageScript/c0d3-app int…
kondanna Jun 23, 2021
dea73e2
removes discord token from graphql query
kondanna Jun 23, 2021
d291d48
removes unnecessary exports
kondanna Jun 23, 2021
2dbd2c7
refactored getUserInfoFromRefreshToken function
kondanna Jun 23, 2021
26f7444
made variable names less verbose
kondanna Jun 23, 2021
42afe72
Merge branch 'master' of https://github.com/garageScript/c0d3-app int…
kondanna Jun 25, 2021
b6f2f5a
changes avatar url to return an actual url, not a hash
kondanna Jun 25, 2021
3cdd672
adds test module for discordAuth
kondanna Jun 26, 2021
94b92f4
await updateUserRefreshToken
kondanna Jun 26, 2021
dbf4003
got first test working
kondanna Jun 26, 2021
4ac3d5f
adds test for getTokenFromAuthCode
kondanna Jun 26, 2021
bc663fd
Merge branch 'master' of https://github.com/garageScript/c0d3-app int…
kondanna Jul 15, 2021
1231451
added Promise to fetch mock return value
kondanna Jul 16, 2021
6c63090
Merge branch 'master' of https://github.com/garageScript/c0d3-app int…
kondanna Jul 16, 2021
bdb3c0a
mocking functions instead of fetch
kondanna Jul 18, 2021
2e7590b
after linter
kondanna Jul 18, 2021
65c4e51
moves mock data to dummy folder
kondanna Jul 18, 2021
7b33751
lints dummy data
kondanna Jul 18, 2021
3a4722a
Merge branch 'master' into discordBack
kondanna Aug 21, 2021
62b0f11
adds helper functions for discord OAuth integration
kondanna Aug 23, 2021
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
132 changes: 132 additions & 0 deletions helpers/discordAuth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
jest.mock('node-fetch')
jest.mock('prisma')
import fetch from 'node-fetch'
import prisma from '../prisma'
import { URLSearchParams } from 'url'

import {
getTokenFromAuthCode,
getUserInfoFromRefreshToken
} from './discordAuth'

describe('getTokenFromAuthCode function', () => {
test('fetch should be called once', async () => {
fetch.mockResolvedValue({ json: () => {} })
const mockRefreshToken = await getTokenFromAuthCode('123')

expect(fetch.mock.calls[0][0]).toBe(
'https://discordapp.com/api/oauth2/token'
)
expect(fetch.mock.calls[0][1]).toEqual({
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=utf-8'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: undefined,
client_secret: undefined,
code: '123',
redirect_uri: 'https://c0d3.com/discord/redir',
scope: 'email guilds.join gdm.join identify'
})
})
})
})

describe('getUserInfoFromRefreshToken function', () => {
beforeEach(() => {
jest.clearAllMocks()
prisma.user.update = jest.fn()
})
it('should call the correct functions and update refresh token in database and return the correct object if refresh token valid', async () => {
fetch.mockResolvedValueOnce({
json: () => ({
refresh_token: 'fakeRefreshToken',
access_token: 'fakeAccessToken'
})
})
fetch.mockResolvedValueOnce({
json: () => ({
id: 'discord123',
username: 'discord-fakeuser',
avatar: 'ea8f5f59aff14450e892321ba128745d'
})
})
const result = await getUserInfoFromRefreshToken(123, 'mockRefreshToken')

expect(fetch.mock.calls.length).toBe(2)

// first fetch function call
expect(fetch.mock.calls[0][0]).toBe(
'https://discordapp.com/api/oauth2/token'
)
expect(fetch.mock.calls[0][1]).toEqual({
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=utf-8'
},
body: new URLSearchParams({
client_id: undefined,
client_secret: undefined,
grant_type: 'refresh_token',
refresh_token: 'mockRefreshToken'
})
})

// second fetch function call
expect(fetch.mock.calls[1][0]).toBe('https://discordapp.com/api/users/@me')
expect(fetch.mock.calls[1][1]).toEqual({
method: 'GET',
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
Authorization: 'Bearer fakeAccessToken'
}
})

// database call
expect(prisma.user.update.mock.calls.length).toBe(1)
expect(prisma.user.update.mock.calls[0][0]).toEqual({
where: {
id: 123
},
data: {
discordRefreshToken: 'fakeRefreshToken'
}
})

expect(result).toEqual({
userId: 'discord123',
username: 'discord-fakeuser',
avatarUrl:
'https://cdn.discordapp.com/avatars/discord123/ea8f5f59aff14450e892321ba128745d.png',
refreshToken: 'fakeRefreshToken'
})
})

it('should throw error and update database with empty string if refresh token invalid', async () => {
fetch.mockResolvedValueOnce({
json: () => ({})
})
try {
const result = await getUserInfoFromRefreshToken(
123,
'invalidRefreshToken'
)
expect(false).toEqual(true) // force test to fail
} catch (error) {
expect(error.message).toBe('refresh token invalid')
}

expect(fetch.mock.calls.length).toBe(1)
expect(prisma.user.update.mock.calls.length).toBe(1)
expect(prisma.user.update.mock.calls[0][0]).toEqual({
where: {
id: 123
},
data: {
discordRefreshToken: ''
}
})
})
})
116 changes: 116 additions & 0 deletions helpers/discordAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import prisma from '../prisma'
import { URLSearchParams } from 'url'
import { User } from '.prisma/client'
import fetch from 'node-fetch'

const discordAPI = 'https://discordapp.com/api'
const client_id = process.env.DISCORD_KEY
const client_secret = process.env.DISCORD_SECRET

type AccessTokenResponse = {
access_token: string
token_type: string
expires_in: number
refresh_token: string
scope: string
}

type UserInfoResponse = {
id: string
username: string
avatar: string
discriminator: string
public_flags: number
locale: string
mfa_enabled: boolean
email: string
verified: boolean
}

type DiscordUserInfo = {
userId: string
username: string
avatarUrl: string
refreshToken: string
}

export const getTokenFromAuthCode = (
code: string
): Promise<AccessTokenResponse> => {
return fetch(`${discordAPI}/oauth2/token`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=utf-8'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id,
client_secret,
code,
redirect_uri: 'https://c0d3.com/discord/redir',
scope: 'email guilds.join gdm.join identify'
})
}).then(r => r.json())
}

const getTokenFromRefreshToken = (
refresh_token: string
): Promise<AccessTokenResponse> => {
return fetch(`${discordAPI}/oauth2/token`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=utf-8'
},
body: new URLSearchParams({
client_id,
client_secret,
grant_type: 'refresh_token',
refresh_token
})
}).then(r => r.json())
}

const getUserInfo = (accessToken: string): Promise<UserInfoResponse> => {
return fetch(`${discordAPI}/users/@me`, {
method: 'GET',
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
Authorization: `Bearer ${accessToken}`
}
}).then(r => r.json())
}

const updateUserRefreshToken = (
userId: number,
refreshToken: string
): Promise<User> => {
return prisma.user.update({
where: {
id: userId
},
data: {
discordRefreshToken: refreshToken
}
})
}

export const getUserInfoFromRefreshToken = async (
userId: number,
refreshToken: string
): Promise<DiscordUserInfo> => {
const tokenResponse = await getTokenFromRefreshToken(refreshToken)
const updatedRefreshToken = tokenResponse.refresh_token || ''
// if updatedRefreshToken is undefined, empty string is stored in db to remove invalid refresh tokens
await updateUserRefreshToken(userId, updatedRefreshToken)

if (!updatedRefreshToken) throw new Error('refresh token invalid')

const { id, username, avatar } = await getUserInfo(tokenResponse.access_token)

return {
userId: id,
username,
avatarUrl: `https://cdn.discordapp.com/avatars/${id}/${avatar}.png`,
refreshToken: updatedRefreshToken
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "discordRefreshToken" VARCHAR(255);
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ model User {
cliToken String? @db.VarChar(255)
emailVerificationToken String? @db.VarChar(255)
tokenExpiration DateTime? @db.Timestamptz(6)
discordRefreshToken String? @db.VarChar(255)
starsMentor Star[] @relation("starMentor")
starsGiven Star[] @relation("starStudent")
submissionsReviewed Submission[] @relation("userReviewedSubmissions")
Expand Down