Skip to content

Commit 040871f

Browse files
authored
feat: handle authentication on top level routes (#542)
1 parent e416b7a commit 040871f

File tree

6 files changed

+147
-13
lines changed

6 files changed

+147
-13
lines changed

src/http/plugins/jwt.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import { ERRORS } from '@internal/errors'
77

88
declare module 'fastify' {
99
interface FastifyRequest {
10+
isAuthenticated: boolean
1011
jwt: string
1112
jwtPayload?: JwtPayload & { role?: string }
1213
owner?: string
1314
}
15+
16+
interface FastifyContextConfig {
17+
allowInvalidJwt?: boolean
18+
}
1419
}
1520

1621
const BEARER = /^Bearer\s+/i
@@ -22,13 +27,25 @@ export const jwt = fastifyPlugin(async (fastify) => {
2227
fastify.addHook('preHandler', async (request, reply) => {
2328
request.jwt = (request.headers.authorization || '').replace(BEARER, '')
2429

30+
if (!request.jwt && request.routeConfig.allowInvalidJwt) {
31+
request.jwtPayload = { role: 'anon' }
32+
request.isAuthenticated = false
33+
return
34+
}
35+
2536
const { secret, jwks } = await getJwtSecret(request.tenantId)
2637

2738
try {
2839
const payload = await verifyJWT(request.jwt, secret, jwks || null)
2940
request.jwtPayload = payload
3041
request.owner = payload.sub
42+
request.isAuthenticated = true
3143
} catch (err: any) {
44+
request.isAuthenticated = false
45+
46+
if (request.routeConfig.allowInvalidJwt) {
47+
return
48+
}
3249
throw ERRORS.AccessDenied(err.message, err)
3350
}
3451
})

src/http/routes/object/getObject.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { IncomingMessage, Server, ServerResponse } from 'http'
44
import { getConfig } from '../../../config'
55
import { AuthenticatedRangeRequest } from '../../types'
66
import { ROUTE_OPERATIONS } from '../operations'
7+
import { ERRORS } from '@internal/errors'
8+
import { Obj } from '@storage/schemas'
79

810
const { storageS3Bucket } = getConfig()
911

@@ -42,10 +44,34 @@ async function requestHandler(
4244
const { download } = request.query
4345
const objectName = request.params['*']
4446

45-
const obj = await request.storage.from(bucketName).findObject(objectName, 'id, version')
46-
4747
// send the object from s3
4848
const s3Key = `${request.tenantId}/${bucketName}/${objectName}`
49+
const bucket = await request.storage.asSuperUser().findBucket(bucketName, 'id,public', {
50+
dontErrorOnEmpty: true,
51+
})
52+
53+
// The request is not authenticated
54+
if (!request.isAuthenticated) {
55+
// The bucket must be public to access its content
56+
if (!bucket?.public) {
57+
throw ERRORS.AccessDenied('Access denied to this bucket')
58+
}
59+
}
60+
61+
// The request is authenticated
62+
if (!bucket) {
63+
throw ERRORS.NoSuchBucket(bucketName)
64+
}
65+
66+
let obj: Obj | undefined
67+
68+
if (bucket.public) {
69+
// request is authenticated but we still use the superUser as we don't need to check RLS
70+
obj = await request.storage.asSuperUser().from(bucketName).findObject(objectName, 'id, version')
71+
} else {
72+
// request is authenticated use RLS
73+
obj = await request.storage.from(bucketName).findObject(objectName, 'id, version')
74+
}
4975

5076
return request.storage.renderer('asset').render(request, response, {
5177
bucket: storageS3Bucket,
@@ -85,14 +111,14 @@ export default async function routes(fastify: FastifyInstance) {
85111
// @todo add success response schema here
86112
schema: {
87113
params: getObjectParamsSchema,
88-
headers: { $ref: 'authSchema#' },
89114
summary: 'Get object',
90-
description: 'use GET /object/authenticated/{bucketName} instead',
115+
description: 'Serve objects',
91116
response: { '4xx': { $ref: 'errorSchema#' } },
92117
tags: ['deprecated'],
93118
},
94119
config: {
95120
operation: { type: ROUTE_OPERATIONS.GET_AUTH_OBJECT },
121+
allowInvalidJwt: true,
96122
},
97123
},
98124
async (request, response) => {

src/http/routes/object/getObjectInfo.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getConfig } from '../../../config'
55
import { AuthenticatedRangeRequest } from '../../types'
66
import { Obj } from '@storage/schemas'
77
import { ROUTE_OPERATIONS } from '../operations'
8+
import { ERRORS } from '@internal/errors'
89

910
const { storageS3Bucket } = getConfig()
1011

@@ -38,11 +39,25 @@ async function requestHandler(
3839

3940
const s3Key = `${request.tenantId}/${bucketName}/${objectName}`
4041

42+
const bucket = await request.storage.asSuperUser().findBucket(bucketName, 'id,public', {
43+
dontErrorOnEmpty: true,
44+
})
45+
46+
// Not Authenticated flow
47+
if (!request.isAuthenticated) {
48+
if (!bucket?.public) {
49+
throw ERRORS.AccessDenied('Access denied to this bucket')
50+
}
51+
}
52+
53+
// Authenticated flow
54+
if (!bucket) {
55+
throw ERRORS.NoSuchBucket(bucketName)
56+
}
57+
4158
let obj: Obj
42-
if (publicRoute) {
43-
await request.storage.asSuperUser().findBucket(bucketName, 'id', {
44-
isPublic: true,
45-
})
59+
60+
if (bucket.public || publicRoute) {
4661
obj = await request.storage
4762
.asSuperUser()
4863
.from(bucketName)
@@ -147,14 +162,14 @@ export async function authenticatedRoutes(fastify: FastifyInstance) {
147162
{
148163
schema: {
149164
params: getObjectParamsSchema,
150-
headers: { $ref: 'authSchema#' },
151165
summary,
152166
description: 'use HEAD /object/authenticated/{bucketName} instead',
153167
response: { '4xx': { $ref: 'errorSchema#' } },
154168
tags: ['deprecated'],
155169
},
156170
config: {
157171
operation: { type: 'object.get_authenticated_info' },
172+
allowInvalidJwt: true,
158173
},
159174
},
160175
async (request, response) => {
@@ -167,14 +182,14 @@ export async function authenticatedRoutes(fastify: FastifyInstance) {
167182
{
168183
schema: {
169184
params: getObjectParamsSchema,
170-
headers: { $ref: 'authSchema#' },
171185
summary,
172186
description: 'use HEAD /object/authenticated/{bucketName} instead',
173187
response: { '4xx': { $ref: 'errorSchema#' } },
174188
tags: ['deprecated'],
175189
},
176190
config: {
177191
operation: { type: 'object.head_authenticated_info' },
192+
allowInvalidJwt: true,
178193
},
179194
},
180195
async (request, response) => {

src/storage/database/knex.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
SearchObjectOption,
1818
} from './adapter'
1919
import { DatabaseError } from 'pg'
20-
import { DBMigration, getTenantConfig, TenantConnection } from '@internal/database'
20+
import { DBMigration, TenantConnection } from '@internal/database'
2121
import { DbQueryPerformance } from '@internal/monitoring/metrics'
2222
import { isUuid } from '../limits'
2323

src/storage/storage.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getFileSizeLimit, mustBeValidBucketName, parseFileSizeToBytes } from '.
66
import { getConfig } from '../config'
77
import { ObjectStorage } from './object'
88
import { InfoRenderer } from '@storage/renderer/info'
9+
import { logger, logSchema } from '@internal/monitoring'
910

1011
const { requestUrlLengthLimit, storageS3Bucket } = getConfig()
1112

@@ -206,7 +207,9 @@ export class Storage {
206207
return all
207208
}, [] as string[])
208209
// delete files from s3 asynchronously
209-
this.backend.deleteObjects(storageS3Bucket, params)
210+
this.backend.deleteObjects(storageS3Bucket, params).catch((e) => {
211+
logSchema.error(logger, 'Failed to delete objects from s3', { type: 's3', error: e })
212+
})
210213
}
211214

212215
if (deleted?.length !== objects.length) {

src/test/object.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ describe('testing GET object', () => {
6161
expect(S3Backend.prototype.getObject).toBeCalled()
6262
})
6363

64+
test('check if RLS policies are respected: authenticated user is able to read authenticated resource without /authenticated prefix', async () => {
65+
const response = await app().inject({
66+
method: 'GET',
67+
url: '/object/bucket2/authenticated/casestudy.png',
68+
headers: {
69+
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
70+
},
71+
})
72+
expect(response.statusCode).toBe(200)
73+
expect(response.headers['etag']).toBe('abc')
74+
expect(response.headers['last-modified']).toBe('Thu, 12 Aug 2021 16:00:00 GMT')
75+
expect(S3Backend.prototype.getObject).toBeCalled()
76+
})
77+
6478
test('forward 304 and If-Modified-Since/If-None-Match headers', async () => {
6579
const mockGetObject = jest.spyOn(S3Backend.prototype, 'getObject')
6680
mockGetObject.mockRejectedValue({
@@ -99,10 +113,48 @@ describe('testing GET object', () => {
99113
expect(response.headers['cache-control']).toBe('no-cache')
100114
})
101115

116+
test('get authenticated object info without the /authenticated prefix', async () => {
117+
const response = await app().inject({
118+
method: 'HEAD',
119+
url: '/object/bucket2/authenticated/casestudy.png',
120+
headers: {
121+
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
122+
},
123+
})
124+
expect(response.statusCode).toBe(200)
125+
expect(response.headers['etag']).toBe('abc')
126+
expect(response.headers['last-modified']).toBe('Wed, 12 Oct 2022 11:17:02 GMT')
127+
expect(response.headers['content-length']).toBe(3746)
128+
expect(response.headers['cache-control']).toBe('no-cache')
129+
})
130+
131+
test('cannot get authenticated object info without the /authenticated prefix if no jwt is provided', async () => {
132+
const response = await app().inject({
133+
method: 'HEAD',
134+
url: '/object/bucket2/authenticated/casestudy.png',
135+
})
136+
expect(response.statusCode).toBe(400)
137+
})
138+
139+
test('get public object info without using the /public prefix', async () => {
140+
const response = await app().inject({
141+
method: 'HEAD',
142+
url: '/object/public-bucket-2/favicon.ico',
143+
headers: {
144+
authorization: ``,
145+
},
146+
})
147+
expect(response.statusCode).toBe(200)
148+
expect(response.headers['etag']).toBe('abc')
149+
expect(response.headers['last-modified']).toBe('Wed, 12 Oct 2022 11:17:02 GMT')
150+
expect(response.headers['content-length']).toBe(3746)
151+
expect(response.headers['cache-control']).toBe('no-cache')
152+
})
153+
102154
test('get public object info', async () => {
103155
const response = await app().inject({
104156
method: 'HEAD',
105-
url: '/object/public/public-bucket-2/favicon.ico',
157+
url: '/object/public-bucket-2/favicon.ico',
106158
headers: {
107159
authorization: ``,
108160
},
@@ -158,6 +210,18 @@ describe('testing GET object', () => {
158210
expect(S3Backend.prototype.getObject).not.toHaveBeenCalled()
159211
})
160212

213+
test('check if RLS policies are respected: anon user is not able to read authenticated resource without /authenticated prefix', async () => {
214+
const response = await app().inject({
215+
method: 'GET',
216+
url: '/object/bucket2/authenticated/casestudy.png',
217+
headers: {
218+
authorization: `Bearer ${anonKey}`,
219+
},
220+
})
221+
expect(response.statusCode).toBe(400)
222+
expect(S3Backend.prototype.getObject).not.toHaveBeenCalled()
223+
})
224+
161225
test('user is not able to read a resource without Auth header', async () => {
162226
const response = await app().inject({
163227
method: 'GET',
@@ -167,6 +231,15 @@ describe('testing GET object', () => {
167231
expect(S3Backend.prototype.getObject).not.toHaveBeenCalled()
168232
})
169233

234+
test('user is not able to read a resource without Auth header without the /authenticated prefix', async () => {
235+
const response = await app().inject({
236+
method: 'GET',
237+
url: '/object/bucket2/authenticated/casestudy.png',
238+
})
239+
expect(response.statusCode).toBe(400)
240+
expect(S3Backend.prototype.getObject).not.toHaveBeenCalled()
241+
})
242+
170243
test('return 400 when reading a non existent object', async () => {
171244
const response = await app().inject({
172245
method: 'GET',

0 commit comments

Comments
 (0)