Skip to content

Commit dd0ff66

Browse files
CasLubberssvcAPLBotferruhcihan
authored
feat: add JWT verification (#875)
* feat(wip): add correct jwt verification * feat: add jwt verification * feat: add jwt verification * fix: tests * fix: add retry for waiting on tools server * feat: add rate-limiter * fix: set high rate limits * Update src/jwt-verification.ts Co-authored-by: Ferruh <[email protected]> * fix: eslint error * fix: only wait for JWKS if not dev or test environment * fix: add clockTolerance --------- Co-authored-by: svcAPLBot <[email protected]> Co-authored-by: Ferruh <[email protected]>
1 parent ec1601d commit dd0ff66

File tree

9 files changed

+346
-17
lines changed

9 files changed

+346
-17
lines changed

package-lock.json

Lines changed: 66 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@
1616
"@types/json-schema": "7.0.15",
1717
"@types/jsonwebtoken": "9.0.10",
1818
"axios": "1.13.2",
19+
"jose": "^5.2.0",
20+
"async-retry": "^1.3.3",
1921
"clean-deep": "3.4.0",
2022
"cors": "2.8.5",
2123
"debug": "4.4.3",
2224
"deep-diff": "1.0.2",
2325
"envalid": "8.1.1",
2426
"express": "5.2.1",
2527
"express-openapi-validator": "5.6.0",
28+
"express-rate-limit": "^7.4.1",
2629
"fs-extra": "11.3.2",
2730
"generate-password": "1.7.1",
2831
"glob": "13.0.0",
@@ -50,6 +53,7 @@
5053
"@eslint/compat": "2.0.0",
5154
"@redocly/openapi-cli": "1.0.0-beta.95",
5255
"@semantic-release/changelog": "6.0.3",
56+
"@types/async-retry": "^1.4.8",
5357
"@semantic-release/git": "10.0.1",
5458
"@types/debug": "^4.1.12",
5559
"@types/expect": "24.3.2",

src/app.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import logger from 'morgan'
99
import path from 'path'
1010
import { CleanOptions } from 'simple-git'
1111
import { default as Authz } from 'src/authz'
12+
import { waitForJwksReady } from 'src/jwt-verification'
1213
import {
1314
errorMiddleware,
1415
getIo,
@@ -17,6 +18,7 @@ import {
1718
jwtMiddleware,
1819
sessionMiddleware,
1920
} from 'src/middleware'
21+
import { apiRateLimiter, authRateLimiter } from 'src/middleware/rate-limit'
2022
import { setMockIdx } from 'src/mocks'
2123
import { AplResponseObject, OpenAPIDoc, Schema } from 'src/otomi-models'
2224
import { default as OtomiStack } from 'src/otomi-stack'
@@ -157,6 +159,15 @@ export async function initApp(inOtomiStack?: OtomiStack) {
157159
app.use(logger('dev'))
158160
app.use(cors())
159161
app.use(express.json({ limit: env.EXPRESS_PAYLOAD_LIMIT }))
162+
163+
// Apply rate limiting BEFORE JWT verification to prevent DoS attacks
164+
if (!env.isTest) {
165+
app.use('/v1', apiRateLimiter) // General API rate limit for v1
166+
app.use('/v1', authRateLimiter) // Stricter rate limit for JWT verification on v1
167+
app.use('/v2', apiRateLimiter) // General API rate limit for v2
168+
app.use('/v2', authRateLimiter) // Stricter rate limit for JWT verification on v2
169+
}
170+
160171
app.use(jwtMiddleware())
161172
if (env.isDev) {
162173
app.all('/mock/:idx', (req, res, next) => {
@@ -174,6 +185,14 @@ export async function initApp(inOtomiStack?: OtomiStack) {
174185
}
175186
let server: Server | undefined
176187
if (!inOtomiStack && !env.isTest) {
188+
// This prevents JWT verification failures during startup
189+
// Skip JWKS check in development mode to allow local development without Keycloak
190+
if (!env.isDev) {
191+
await waitForJwksReady()
192+
} else {
193+
debug('Skipping JWKS verification in development mode')
194+
}
195+
177196
// initialize full server
178197
const { PORT = 8080 } = process.env
179198
server = app

src/fixtures/jwt.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import jwt, { SignOptions } from 'jsonwebtoken'
22
import nock from 'nock'
3+
import { cleanEnv, SSO_ISSUER } from 'src/validators'
34

4-
const { env } = process
5+
const env = cleanEnv({ SSO_ISSUER })
56

67
const privateKey = `-----BEGIN RSA PRIVATE KEY-----
78
MIIEowIBAAKCAQEAwaZ3afW0/zYy3HfJwAAr83PDdZvADuSJ6jTZk1+jprdHdG6P
@@ -43,14 +44,14 @@ const nockReply = {
4344
},
4445
],
4546
}
46-
env.OIDC_ENDPOINT = 'https://bla.dida'
4747

48-
nock(env.OIDC_ENDPOINT).persist().get('/.well-known/jwks.json').reply(200, nockReply)
48+
nock(env.SSO_ISSUER).persist().get('/protocol/openid-connect/certs').reply(200, nockReply)
4949

5050
export default function getToken(groups: string[], roles?: string[]): string {
5151
const payload = {
5252
name: 'Joe Test',
5353
54+
sub: 'mock-sub-value',
5455
groups,
5556
roles,
5657
}
@@ -59,7 +60,7 @@ export default function getToken(groups: string[], roles?: string[]): string {
5960
algorithm: 'RS256',
6061
expiresIn: '1d',
6162
audience: 'otomi',
62-
issuer: env.OIDC_ENDPOINT,
63+
issuer: env.SSO_ISSUER,
6364
}
6465

6566
let token

src/jwt-verification.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { createRemoteJWKSet, JWTPayload, jwtVerify } from 'jose'
2+
import Debug from 'debug'
3+
import retry from 'async-retry'
4+
import {
5+
cleanEnv,
6+
JWT_AUDIENCE,
7+
SSO_ISSUER,
8+
SSO_JWKS_URI,
9+
STARTUP_RETRY_COUNT,
10+
STARTUP_RETRY_INTERVAL_MS,
11+
} from 'src/validators'
12+
13+
const debug = Debug('otomi:jwt')
14+
const env = cleanEnv({ SSO_ISSUER, JWT_AUDIENCE, SSO_JWKS_URI, STARTUP_RETRY_COUNT, STARTUP_RETRY_INTERVAL_MS })
15+
const JWKS_URL = env.SSO_JWKS_URI
16+
17+
// Create remote JWKS - automatically caches and refreshes keys
18+
const JWKS = createRemoteJWKSet(new URL(JWKS_URL))
19+
20+
export interface AppJWTPayload extends JWTPayload {
21+
name: string
22+
email: string
23+
sub: string
24+
groups: string[]
25+
roles: string[]
26+
}
27+
28+
export async function verifyJwt(token: string): Promise<AppJWTPayload> {
29+
// Remove "Bearer " prefix if present
30+
const bearerToken = token.replace(/^Bearer\s+/i, '')
31+
32+
try {
33+
const { payload } = await jwtVerify(bearerToken, JWKS, {
34+
issuer: env.SSO_ISSUER,
35+
audience: env.JWT_AUDIENCE,
36+
clockTolerance: 60, // 60 seconds clock tolerance for clock skew between issuer and verifier
37+
})
38+
if (!payload.email || !payload.name || !payload.sub) {
39+
throw new Error('JWT missing required claims')
40+
}
41+
debug('JWT verified successfully:', payload.sub)
42+
43+
return {
44+
payload,
45+
email: payload.email as string,
46+
name: payload.name as string,
47+
sub: payload.sub,
48+
groups: (payload.groups as string[]) || [],
49+
roles: (payload.roles as string[]) || [],
50+
}
51+
} catch (error: any) {
52+
debug('JWT verification failed:', error.message)
53+
throw error
54+
}
55+
}
56+
57+
export async function waitForJwksReady(): Promise<void> {
58+
debug('Waiting for JWKS endpoint to become ready...')
59+
60+
await retry(
61+
async () => {
62+
try {
63+
// Try a dummy verification with an invalid token
64+
// We only care that JWKS fetch succeeds, not that the token is valid
65+
const invalidJWT = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.invalid_signature'
66+
await verifyJwt(invalidJWT)
67+
} catch (err: any) {
68+
// If we get a JWT verification error, JWKS successfully fetched and processed the token
69+
// This means JWKS is working! (Even though token was rejected - that's expected)
70+
const isJwtVerificationError =
71+
err?.code?.startsWith('ERR_JW') || // jose library error codes (ERR_JWS_INVALID, ERR_JWT_INVALID, etc.)
72+
err?.message?.includes('signature') ||
73+
err?.message?.includes('invalid') ||
74+
err?.message?.includes('expired') ||
75+
err?.message?.includes('claim')
76+
77+
if (isJwtVerificationError) {
78+
debug('JWKS endpoint reachable (token verification worked) — continuing startup.')
79+
return
80+
}
81+
82+
debug(`JWKS not ready yet: ${err.message}, retrying...`)
83+
throw err
84+
}
85+
},
86+
{
87+
retries: env.STARTUP_RETRY_COUNT,
88+
minTimeout: env.STARTUP_RETRY_INTERVAL_MS,
89+
maxTimeout: env.STARTUP_RETRY_INTERVAL_MS,
90+
},
91+
)
92+
}

0 commit comments

Comments
 (0)