diff --git a/helpers/discordAuth.test.js b/helpers/discordAuth.test.js new file mode 100644 index 000000000..4dca66c48 --- /dev/null +++ b/helpers/discordAuth.test.js @@ -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: '' + } + }) + }) +}) diff --git a/helpers/discordAuth.ts b/helpers/discordAuth.ts new file mode 100644 index 000000000..e1916c945 --- /dev/null +++ b/helpers/discordAuth.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + return prisma.user.update({ + where: { + id: userId + }, + data: { + discordRefreshToken: refreshToken + } + }) +} + +export const getUserInfoFromRefreshToken = async ( + userId: number, + refreshToken: string +): Promise => { + 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 + } +} diff --git a/prisma/migrations/20210609140541_adds_refresh_token_to_user/migration.sql b/prisma/migrations/20210609140541_adds_refresh_token_to_user/migration.sql new file mode 100644 index 000000000..17bde5dcb --- /dev/null +++ b/prisma/migrations/20210609140541_adds_refresh_token_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "discordRefreshToken" VARCHAR(255); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71f7c777b..1064f1dfc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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")