Skip to content

Commit

Permalink
refactor(backend): context hook
Browse files Browse the repository at this point in the history
Motivation
----------
This fixes #1503.

This is necessary before I can properly implement role based authorization (for the admins).

How to test
-----------
1. CI should be green
  • Loading branch information
roschaefer committed Aug 14, 2024
1 parent e05a575 commit a2a71f7
Show file tree
Hide file tree
Showing 21 changed files with 456 additions and 387 deletions.
2 changes: 1 addition & 1 deletion backend/jest.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"coverageThreshold": {
"global": {
"statements": 95,
"branches": 80,
"branches": 79,
"functions": 96,
"lines": 96
}
Expand Down
180 changes: 60 additions & 120 deletions backend/src/auth/authChecker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,40 @@ import { ApolloServer } from '@apollo/server'
import { prisma } from '#src/prisma'
import { createTestServer } from '#src/server/server'

import type { Context } from '#src/server/context'

// eslint-disable-next-line jest/no-untyped-mock-factory
jest.mock('axios', () => {
return {
create: jest.fn().mockImplementation(() => {
return {
interceptors: {
request: {
use: jest.fn(),
},
},
post: jest.fn().mockImplementation(() => ({
data: {},
})),
get: jest.fn().mockImplementation(() => ({
data: {},
})),
}
}),
}
})
import type { Context } from '#src/context'
import type { UserWithProfile } from '#src/prisma'

let testServer: ApolloServer<Context>

// uses joinMyTable query
const query = `
{
currentUser {
id
username
name
introduction
details {
id
category
text
}
}
}
`

describe('authChecker', () => {
beforeAll(async () => {
testServer = await createTestServer()
})

describe('no token in context', () => {
describe('unauthenticated', () => {
it('returns access denied error', async () => {
await expect(
testServer.executeOperation(
{
query: 'mutation { joinMyTable }',
query,
},
{ contextValue: { dataSources: { prisma } } },
{ contextValue: { user: null, dataSources: { prisma } } },
),
).resolves.toMatchObject({
body: {
Expand All @@ -60,110 +55,55 @@ describe('authChecker', () => {
})
})

describe('valid token in context', () => {
it('has no user in database', async () => {
await expect(prisma.user.findMany()).resolves.toHaveLength(0)
})

describe('if prisma client throws an error, e.g. because of pending migrations', () => {
const failingPrisma = {
user: { findUnique: jest.fn(prisma.user.findUnique).mockRejectedValue('Ouch!') },
} as unknown as typeof prisma
describe('authenticated', () => {
const user: UserWithProfile = {
id: 81,
username: 'mockedUser',
name: 'Bibi Bloxberg',
introduction: null,
availability: null,
createdAt: new Date(Date.parse('2024-08-07T21:20:17.484Z')),
meetingId: null,
meeting: null,
userDetail: [{ id: 5, category: 'work', text: 'Schwer am Schuften', userId: 81 }],
socialMedia: [],
}

it('resolves to "INTERNAL_SERVER_ERROR" instead of "UNAUTHENTICATED"', async () => {
await expect(
testServer.executeOperation(
{ query: 'mutation { joinMyTable }' },
{ contextValue: { token: 'token', dataSources: { prisma: failingPrisma } } },
),
).resolves.toEqual({
http: expect.anything(), // eslint-disable-line @typescript-eslint/no-unsafe-assignment
body: {
kind: 'single',
singleResult: {
data: null,
errors: [
{
extensions: { code: 'INTERNAL_SERVER_ERROR' },
locations: expect.anything(), // eslint-disable-line @typescript-eslint/no-unsafe-assignment
message: 'Unexpected error value: "Ouch!"',
path: ['joinMyTable'],
},
],
},
},
})
})
})

describe('first call', () => {
let userId: number

it('creates user in database', async () => {
await testServer.executeOperation(
it('checks if a user is authenticated', async () => {
await expect(
testServer.executeOperation(
{
query: 'mutation { joinMyTable }',
query,
},
{
contextValue: {
token: 'token',
user,
dataSources: { prisma },
},
},
)
const result = await prisma.user.findMany()
userId = result[0].id
expect(result).toHaveLength(1)
expect(result).toEqual([
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
id: expect.any(Number),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
createdAt: expect.any(Date),
name: 'User',
username: 'mockedUser',
introduction: null,
availability: null,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
meetingId: expect.any(Number),
},
])
})

it('creates CREATE USER event', async () => {
const result = await prisma.event.findMany()
expect(result).toHaveLength(1)
expect(result).toEqual([
expect.objectContaining({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
id: expect.any(Number),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
createdAt: expect.any(Date),
type: 'CREATE_USER',
involvedUserId: userId,
}),
])
})
})

describe('second call', () => {
it('has the user in database', async () => {
await expect(prisma.user.findMany()).resolves.toHaveLength(1)
})

it('has the same user in database', async () => {
await testServer.executeOperation(
{
query: 'mutation { joinMyTable }',
},
{
contextValue: {
token: 'token',
dataSources: { prisma },
),
).resolves.toMatchObject({
body: {
kind: 'single',
singleResult: {
data: {
currentUser: {
details: [
{
category: 'work',
id: 5,
text: 'Schwer am Schuften',
},
],
id: 81,
introduction: null,
name: 'Bibi Bloxberg',
username: 'mockedUser',
},
},
errors: undefined,
},
)
await expect(prisma.user.findMany()).resolves.toHaveLength(1)
},
})
})
})
Expand Down
69 changes: 3 additions & 66 deletions backend/src/auth/authChecker.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,7 @@
import { createRemoteJWKSet } from 'jose'
import { AuthChecker } from 'type-graphql'

import { CONFIG } from '#config/config'
import { EVENT_CREATE_USER } from '#src/event/Events'
import { Context } from '#src/server/context'
import { Context } from '#src/context'

import { jwtVerify } from './jwtVerify'

import type { prisma as Prisma, UserWithProfile } from '#src/prisma'

export interface CustomJwtPayload {
nickname: string
name: string
}

const JWKS = createRemoteJWKSet(new URL(CONFIG.JWKS_URI))

export const authChecker: AuthChecker<Context> = async ({ context }) => {
const { token, dataSources } = context
const { prisma } = dataSources

if (!token) return false

let payload: CustomJwtPayload
try {
const decoded = await jwtVerify<CustomJwtPayload>(token, JWKS)
payload = decoded.payload
} catch (err) {
return false
}

if (payload) {
const { nickname, name } = payload
const user = await contextUser(prisma)(nickname, name)
context.user = user
return true
}

return false
export const authChecker: AuthChecker<Context> = ({ context }) => {
return !!context.user
}

const contextUser =
(prisma: typeof Prisma) =>
async (username: string, name: string): Promise<UserWithProfile> => {
let user: UserWithProfile | null = await prisma.user.findUnique({
where: {
username,
},
include: {
meeting: true,
userDetail: true,
socialMedia: true,
},
})
if (user) return user
user = await prisma.user.create({
data: {
username,
name,
},
include: {
meeting: true,
userDetail: true,
socialMedia: true,
},
})
await EVENT_CREATE_USER(user.id)
return user
}
29 changes: 0 additions & 29 deletions backend/src/auth/jwtVerify.ts

This file was deleted.

30 changes: 30 additions & 0 deletions backend/src/context/context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { jwtVerify } from 'jose'

import { context } from './context'
import { findOrCreateUser } from './findOrCreateUser'

import type { CustomJwtPayload } from './context'

jest.mock('./findOrCreateUser')
jest.mock('jose')

const mockedFindOrCreateUser = jest.mocked(findOrCreateUser)
const mockedJwtVerify = jest.mocked(jwtVerify<CustomJwtPayload>)

describe('context', () => {
describe('if prisma client throws an error, e.g. because of pending migrations', () => {
beforeEach(() => {
mockedFindOrCreateUser.mockRejectedValue('Ouch!')
const jwtVerifyPayload = { payload: { nickname: 'nickname', name: 'name' } }
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-explicit-any
mockedJwtVerify.mockResolvedValue(jwtVerifyPayload as any)
})

it('resolves to "INTERNAL_SERVER_ERROR" instead of "UNAUTHENTICATED"', async () => {
const contextArgs = [{ req: { headers: { authorization: 'Bearer foobar' } } }] as Parameters<
typeof context
>
await expect(context(...contextArgs)).rejects.toBe('Ouch!')
})
})
})
Loading

0 comments on commit a2a71f7

Please sign in to comment.