Skip to content

Commit c3cbf2e

Browse files
fenositslenny
andauthored
fix: switch from jsonwebtoken to jose for jwt signing/verification (#678) (#679)
- Supports async jwt signing/verification using libuv - Add support for OKP (ed25519/Ed448) jwks - Cleaner implementation across jwt code Co-authored-by: Lenny <[email protected]>
1 parent 9e07f17 commit c3cbf2e

File tree

24 files changed

+13891
-10314
lines changed

24 files changed

+13891
-10314
lines changed

Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Base stage for shared environment setup
2-
FROM node:20-alpine3.20 as base
2+
FROM node:22-alpine3.21 as base
33
RUN apk add --no-cache g++ make python3
44
WORKDIR /app
55
COPY package.json package-lock.json ./
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS database_pool_mode TEXT NULL;

package-lock.json

+13,290-10,166
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"node": ">= 14.0.0"
2929
},
3030
"dependencies": {
31+
"@aws-sdk/client-ecs": "^3.795.0",
3132
"@aws-sdk/client-s3": "3.654.0",
3233
"@aws-sdk/lib-storage": "3.654.0",
3334
"@aws-sdk/s3-request-presigner": "3.654.0",

src/config.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ type StorageConfigType = {
7171
isMultitenant: boolean
7272
jwtSecret: string
7373
jwtAlgorithm: string
74+
jwtCachingEnabled: boolean
7475
jwtJWKS?: JwksConfig
7576
multitenantDatabaseUrl?: string
7677
dbAnonRole: string
@@ -86,6 +87,7 @@ type StorageConfigType = {
8687
databaseURL: string
8788
databaseSSLRootCert?: string
8889
databasePoolURL?: string
90+
databasePoolMode?: 'single_use' | 'recycle'
8991
databaseMaxConnections: number
9092
databaseFreePoolAfterInactivity: number
9193
databaseConnectionTimeout: number
@@ -262,6 +264,7 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType {
262264
encryptionKey: getOptionalConfigFromEnv('AUTH_ENCRYPTION_KEY', 'ENCRYPTION_KEY') || '',
263265
jwtSecret: getOptionalIfMultitenantConfigFromEnv('AUTH_JWT_SECRET', 'PGRST_JWT_SECRET') || '',
264266
jwtAlgorithm: getOptionalConfigFromEnv('AUTH_JWT_ALGORITHM', 'PGRST_JWT_ALGORITHM') || 'HS256',
267+
jwtCachingEnabled: getOptionalConfigFromEnv('JWT_CACHING_ENABLED') === 'true',
265268

266269
// Upload
267270
uploadFileSizeLimit: Number(
@@ -353,6 +356,7 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType {
353356
databaseSSLRootCert: getOptionalConfigFromEnv('DATABASE_SSL_ROOT_CERT'),
354357
databaseURL: getOptionalIfMultitenantConfigFromEnv('DATABASE_URL') || '',
355358
databasePoolURL: getOptionalConfigFromEnv('DATABASE_POOL_URL') || '',
359+
databasePoolMode: getOptionalConfigFromEnv('DATABASE_POOL_MODE'),
356360
databaseMaxConnections: parseInt(
357361
getOptionalConfigFromEnv('DATABASE_MAX_CONNECTIONS') || '20',
358362
10
@@ -506,8 +510,7 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType {
506510

507511
if (jwtJWKS) {
508512
try {
509-
const parsed = JSON.parse(jwtJWKS)
510-
config.jwtJWKS = parsed
513+
config.jwtJWKS = JSON.parse(jwtJWKS)
511514
} catch {
512515
throw new Error('Unable to parse JWT_JWKS value to JSON')
513516
}

src/http/plugins/jwt.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import fastifyPlugin from 'fastify-plugin'
22
import { JWTPayload } from 'jose'
33

4-
import { verifyJWT } from '@internal/auth'
4+
import { verifyJWTWithCache, verifyJWT } from '@internal/auth'
55
import { getJwtSecret } from '@internal/database'
66
import { ERRORS } from '@internal/errors'
7+
import { getConfig } from '../../config'
78

89
declare module 'fastify' {
910
interface FastifyRequest {
@@ -18,6 +19,8 @@ declare module 'fastify' {
1819
}
1920
}
2021

22+
const { jwtCachingEnabled } = getConfig()
23+
2124
const BEARER = /^Bearer\s+/i
2225

2326
export const jwt = fastifyPlugin(
@@ -37,7 +40,10 @@ export const jwt = fastifyPlugin(
3740
const { secret, jwks } = await getJwtSecret(request.tenantId)
3841

3942
try {
40-
const payload = await verifyJWT(request.jwt, secret, jwks || null)
43+
const payload = await (jwtCachingEnabled
44+
? verifyJWTWithCache(request.jwt, secret, jwks || null)
45+
: verifyJWT(request.jwt, secret, jwks || null))
46+
4147
request.jwtPayload = payload
4248
request.owner = payload.sub
4349
request.isAuthenticated = true

src/http/routes/admin/tenants.ts

+9
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const patchSchema = {
2626
anonKey: { type: 'string' },
2727
databaseUrl: { type: 'string' },
2828
databasePoolUrl: { type: 'string', nullable: true },
29+
databasePoolMode: { type: 'string', nullable: true },
2930
maxConnections: { type: 'number' },
3031
jwks: { type: 'object', nullable: true },
3132
fileSizeLimit: { type: 'number' },
@@ -112,6 +113,7 @@ export default async function routes(fastify: FastifyInstance) {
112113
anon_key,
113114
database_url,
114115
database_pool_url,
116+
database_pool_mode,
115117
max_connections,
116118
file_size_limit,
117119
jwt_secret,
@@ -130,6 +132,7 @@ export default async function routes(fastify: FastifyInstance) {
130132
anonKey: decrypt(anon_key),
131133
databaseUrl: decrypt(database_url),
132134
databasePoolUrl: database_pool_url ? decrypt(database_pool_url) : undefined,
135+
databasePoolMode: database_pool_mode,
133136
maxConnections: max_connections ? Number(max_connections) : undefined,
134137
fileSizeLimit: Number(file_size_limit),
135138
jwtSecret: decrypt(jwt_secret),
@@ -164,6 +167,7 @@ export default async function routes(fastify: FastifyInstance) {
164167
anon_key,
165168
database_url,
166169
database_pool_url,
170+
database_pool_mode,
167171
max_connections,
168172
file_size_limit,
169173
jwt_secret,
@@ -188,6 +192,7 @@ export default async function routes(fastify: FastifyInstance) {
188192
: database_pool_url
189193
? decrypt(database_pool_url)
190194
: undefined,
195+
databasePoolMode: database_pool_mode,
191196
maxConnections: max_connections ? Number(max_connections) : undefined,
192197
fileSizeLimit: Number(file_size_limit),
193198
jwtSecret: decrypt(jwt_secret),
@@ -217,6 +222,7 @@ export default async function routes(fastify: FastifyInstance) {
217222
const {
218223
anonKey,
219224
databaseUrl,
225+
databasePoolMode,
220226
fileSizeLimit,
221227
jwtSecret,
222228
jwks,
@@ -233,6 +239,7 @@ export default async function routes(fastify: FastifyInstance) {
233239
anon_key: encrypt(anonKey),
234240
database_url: encrypt(databaseUrl),
235241
database_pool_url: databasePoolUrl ? encrypt(databasePoolUrl) : undefined,
242+
database_pool_mode: databasePoolMode,
236243
max_connections: maxConnections ? Number(maxConnections) : undefined,
237244
file_size_limit: fileSizeLimit,
238245
jwt_secret: encrypt(jwtSecret),
@@ -280,6 +287,7 @@ export default async function routes(fastify: FastifyInstance) {
280287
serviceKey,
281288
features,
282289
databasePoolUrl,
290+
databasePoolMode,
283291
maxConnections,
284292
tracingMode,
285293
disableEvents,
@@ -295,6 +303,7 @@ export default async function routes(fastify: FastifyInstance) {
295303
: databasePoolUrl === null
296304
? null
297305
: undefined,
306+
database_pool_mode: databasePoolMode,
298307
max_connections: maxConnections ? Number(maxConnections) : undefined,
299308
file_size_limit: fileSizeLimit,
300309
jwt_secret: jwtSecret !== undefined ? encrypt(jwtSecret) : undefined,

src/internal/auth/jwt.ts

+50
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
JWTVerifyGetKey,
1111
SignJWT,
1212
} from 'jose'
13+
import { LRUCache } from 'lru-cache'
14+
import objectSizeOf from 'object-sizeof'
1315

1416
const { jwtAlgorithm } = getConfig()
1517

@@ -111,6 +113,54 @@ function getJWTAlgorithms(jwks: JwksConfig | null) {
111113
return algorithms
112114
}
113115

116+
const jwtCache = new LRUCache<string, { token: string; payload: JWTPayload }>({
117+
maxSize: 1024 * 1024 * 50, // 50MB
118+
sizeCalculation: (value) => {
119+
return objectSizeOf(value)
120+
},
121+
ttlResolution: 5000, // 5 seconds
122+
})
123+
124+
/**
125+
* Verifies if a JWT is valid and caches the payload
126+
* for the duration of the token's expiration time
127+
* @param token
128+
* @param secret
129+
* @param jwks
130+
*/
131+
export async function verifyJWTWithCache(
132+
token: string,
133+
secret: string,
134+
jwks?: { keys: JwksConfigKey[] } | null
135+
) {
136+
const cachedVerification = jwtCache.get(token)
137+
if (
138+
cachedVerification &&
139+
cachedVerification.payload.exp &&
140+
cachedVerification.payload.exp * 1000 > Date.now()
141+
) {
142+
return Promise.resolve(cachedVerification.payload)
143+
}
144+
145+
try {
146+
const payload = await verifyJWT(token, secret, jwks)
147+
if (!payload.exp) {
148+
return payload
149+
}
150+
151+
jwtCache.set(
152+
token,
153+
{ token, payload: payload },
154+
{
155+
ttl: payload.exp * 1000 - Date.now(),
156+
}
157+
)
158+
return payload
159+
} catch (e) {
160+
throw e
161+
}
162+
}
163+
114164
/**
115165
* Verifies if a JWT is valid
116166
* @param token

src/internal/cluster/cluster.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ClusterDiscoveryECS } from '@internal/cluster/ecs'
2+
3+
import { EventEmitter } from 'node:events'
4+
import { logger } from '@internal/monitoring'
5+
6+
const clusterEvent = new EventEmitter()
7+
8+
export class Cluster {
9+
static size: number = 0
10+
protected static watcher?: NodeJS.Timeout = undefined
11+
12+
static on(event: string, listener: (...args: any[]) => void) {
13+
clusterEvent.on(event, listener)
14+
}
15+
16+
static async init(abortSignal: AbortSignal) {
17+
if (process.env.CLUSTER_DISCOVERY === 'ECS') {
18+
const cluster = new ClusterDiscoveryECS()
19+
Cluster.size = await cluster.getClusterSize()
20+
21+
logger.info(`[Cluster] Initial cluster size ${Cluster.size}`, {
22+
type: 'cluster',
23+
clusterSize: Cluster.size,
24+
})
25+
26+
Cluster.watcher = setInterval(() => {
27+
cluster
28+
.getClusterSize()
29+
.then((size) => {
30+
if (size !== Cluster.size) {
31+
clusterEvent.emit('change', { size })
32+
}
33+
Cluster.size = size
34+
})
35+
.catch((e) => {
36+
console.error('Error getting cluster size', e)
37+
})
38+
}, 20 * 1000)
39+
40+
abortSignal.addEventListener(
41+
'abort',
42+
() => {
43+
if (Cluster.watcher) {
44+
clearInterval(Cluster.watcher)
45+
clusterEvent.removeAllListeners()
46+
Cluster.watcher = undefined
47+
}
48+
},
49+
{ once: true }
50+
)
51+
}
52+
}
53+
}

src/internal/cluster/ecs.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ECSClient, ListTasksCommand } from '@aws-sdk/client-ecs'
2+
import axios from 'axios'
3+
import { DesiredStatus } from '@aws-sdk/client-ecs/dist-types/models/models_0'
4+
5+
export class ClusterDiscoveryECS {
6+
private client: ECSClient
7+
8+
constructor() {
9+
this.client = new ECSClient()
10+
}
11+
12+
async getClusterSize() {
13+
if (!process.env.ECS_CONTAINER_METADATA_URI) {
14+
throw new Error('ECS_CONTAINER_METADATA_URI is not set')
15+
}
16+
17+
const [running, pending] = await Promise.all([
18+
this.listTasks('RUNNING'),
19+
this.listTasks('PENDING'),
20+
])
21+
22+
return running + pending
23+
}
24+
25+
private async listTasks(status: DesiredStatus) {
26+
const respMetadata = await axios.get(`${process.env.ECS_CONTAINER_METADATA_URI}/task`)
27+
28+
const command = new ListTasksCommand({
29+
serviceName: respMetadata.data.ServiceName,
30+
cluster: respMetadata.data.Cluster,
31+
desiredStatus: status,
32+
})
33+
const response = await this.client.send(command)
34+
return response.taskArns?.length || 0
35+
}
36+
}

src/internal/cluster/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './cluster'

src/internal/concurrency/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './mutex'
2+
export * from './wait'
23
export * from './async-abort-controller'
34
export * from './merge-async-itertor'

src/internal/concurrency/wait.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function wait(ms: number) {
2+
return new Promise((resolve) => {
3+
setTimeout(resolve, ms)
4+
})
5+
}

src/internal/database/client.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { getConfig } from '../../config'
22
import { getTenantConfig } from './tenant'
3-
import { User, TenantConnection } from './connection'
3+
import { TenantConnection } from './connection'
4+
import { User } from './pool'
45
import { ERRORS } from '@internal/errors'
6+
import { Cluster } from '@internal/cluster'
57

68
interface ConnectionOptions {
79
host: string
@@ -21,17 +23,18 @@ interface ConnectionOptions {
2123
* @param options
2224
*/
2325
export async function getPostgresConnection(options: ConnectionOptions): Promise<TenantConnection> {
24-
const dbCredentials = await getDbCredentials(options.tenantId, options.host, {
26+
const dbCredentials = await getDbSettings(options.tenantId, options.host, {
2527
disableHostCheck: options.disableHostCheck,
2628
})
2729

2830
return await TenantConnection.create({
2931
...dbCredentials,
3032
...options,
33+
clusterSize: Cluster.size,
3134
})
3235
}
3336

34-
async function getDbCredentials(
37+
async function getDbSettings(
3538
tenantId: string,
3639
host: string | undefined,
3740
options?: { disableHostCheck?: boolean }
@@ -42,11 +45,13 @@ async function getDbCredentials(
4245
databaseURL,
4346
databaseMaxConnections,
4447
requestXForwardedHostRegExp,
48+
databasePoolMode,
4549
} = getConfig()
4650

4751
let dbUrl = databasePoolURL || databaseURL
4852
let maxConnections = databaseMaxConnections
4953
let isExternalPool = Boolean(databasePoolURL)
54+
let isSingleUse = !databasePoolMode || databasePoolMode === 'single_use'
5055

5156
if (isMultitenant) {
5257
if (!tenantId) {
@@ -70,11 +75,13 @@ async function getDbCredentials(
7075
dbUrl = tenant.databasePoolUrl || tenant.databaseUrl
7176
isExternalPool = Boolean(tenant.databasePoolUrl)
7277
maxConnections = tenant.maxConnections ?? maxConnections
78+
isSingleUse = tenant.databasePoolMode ? tenant.databasePoolMode !== 'recycled' : isSingleUse
7379
}
7480

7581
return {
7682
dbUrl,
7783
isExternalPool,
7884
maxConnections,
85+
isSingleUse,
7986
}
8087
}

0 commit comments

Comments
 (0)