Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
"@types/json-schema": "7.0.15",
"@types/jsonwebtoken": "9.0.10",
"axios": "1.13.2",
"jose": "^5.2.0",
"async-retry": "^1.3.3",
"clean-deep": "3.4.0",
"cors": "2.8.5",
"debug": "4.4.3",
"deep-diff": "1.0.2",
"envalid": "8.1.1",
"express": "5.2.1",
"express-openapi-validator": "5.6.0",
"express-rate-limit": "^7.4.1",
"fs-extra": "11.3.2",
"generate-password": "1.7.1",
"glob": "13.0.0",
Expand Down Expand Up @@ -50,6 +53,7 @@
"@eslint/compat": "2.0.0",
"@redocly/openapi-cli": "1.0.0-beta.95",
"@semantic-release/changelog": "6.0.3",
"@types/async-retry": "^1.4.8",
"@semantic-release/git": "10.0.1",
"@types/debug": "^4.1.12",
"@types/expect": "24.3.2",
Expand Down
19 changes: 19 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import logger from 'morgan'
import path from 'path'
import { CleanOptions } from 'simple-git'
import { default as Authz } from 'src/authz'
import { waitForJwksReady } from 'src/jwt-verification'
import {
errorMiddleware,
getIo,
Expand All @@ -17,6 +18,7 @@ import {
jwtMiddleware,
sessionMiddleware,
} from 'src/middleware'
import { apiRateLimiter, authRateLimiter } from 'src/middleware/rate-limit'
import { setMockIdx } from 'src/mocks'
import { AplResponseObject, OpenAPIDoc, Schema } from 'src/otomi-models'
import { default as OtomiStack } from 'src/otomi-stack'
Expand Down Expand Up @@ -157,6 +159,15 @@ export async function initApp(inOtomiStack?: OtomiStack) {
app.use(logger('dev'))
app.use(cors())
app.use(express.json({ limit: env.EXPRESS_PAYLOAD_LIMIT }))

// Apply rate limiting BEFORE JWT verification to prevent DoS attacks
if (!env.isTest) {
app.use('/v1', apiRateLimiter) // General API rate limit for v1
app.use('/v1', authRateLimiter) // Stricter rate limit for JWT verification on v1
app.use('/v2', apiRateLimiter) // General API rate limit for v2
app.use('/v2', authRateLimiter) // Stricter rate limit for JWT verification on v2
}

app.use(jwtMiddleware())
if (env.isDev) {
app.all('/mock/:idx', (req, res, next) => {
Expand All @@ -174,6 +185,14 @@ export async function initApp(inOtomiStack?: OtomiStack) {
}
let server: Server | undefined
if (!inOtomiStack && !env.isTest) {
// This prevents JWT verification failures during startup
// Skip JWKS check in development mode to allow local development without Keycloak
if (!env.isDev) {
await waitForJwksReady()
} else {
debug('Skipping JWKS verification in development mode')
}

// initialize full server
const { PORT = 8080 } = process.env
server = app
Expand Down
9 changes: 5 additions & 4 deletions src/fixtures/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import jwt, { SignOptions } from 'jsonwebtoken'
import nock from 'nock'
import { cleanEnv, SSO_ISSUER } from 'src/validators'

const { env } = process
const env = cleanEnv({ SSO_ISSUER })

const privateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAwaZ3afW0/zYy3HfJwAAr83PDdZvADuSJ6jTZk1+jprdHdG6P
Expand Down Expand Up @@ -43,14 +44,14 @@ const nockReply = {
},
],
}
env.OIDC_ENDPOINT = 'https://bla.dida'

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

export default function getToken(groups: string[], roles?: string[]): string {
const payload = {
name: 'Joe Test',
email: 'test.user@test.net',
sub: 'mock-sub-value',
groups,
roles,
}
Expand All @@ -59,7 +60,7 @@ export default function getToken(groups: string[], roles?: string[]): string {
algorithm: 'RS256',
expiresIn: '1d',
audience: 'otomi',
issuer: env.OIDC_ENDPOINT,
issuer: env.SSO_ISSUER,
}

let token
Expand Down
91 changes: 91 additions & 0 deletions src/jwt-verification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { createRemoteJWKSet, JWTPayload, jwtVerify } from 'jose'
import Debug from 'debug'
import retry from 'async-retry'
import {
cleanEnv,
JWT_AUDIENCE,
SSO_ISSUER,
SSO_JWKS_URI,
STARTUP_RETRY_COUNT,
STARTUP_RETRY_INTERVAL_MS,
} from 'src/validators'

const debug = Debug('otomi:jwt')
const env = cleanEnv({ SSO_ISSUER, JWT_AUDIENCE, SSO_JWKS_URI, STARTUP_RETRY_COUNT, STARTUP_RETRY_INTERVAL_MS })
const JWKS_URL = env.SSO_JWKS_URI

// Create remote JWKS - automatically caches and refreshes keys
const JWKS = createRemoteJWKSet(new URL(JWKS_URL))

export interface AppJWTPayload extends JWTPayload {
name: string
email: string
sub: string
groups: string[]
roles: string[]
}

export async function verifyJwt(token: string): Promise<AppJWTPayload> {
// Remove "Bearer " prefix if present
const bearerToken = token.replace(/^Bearer\s+/i, '')

try {
const { payload } = await jwtVerify(bearerToken, JWKS, {
issuer: env.SSO_ISSUER,
audience: env.JWT_AUDIENCE,
})
if (!payload.email || !payload.name || !payload.sub) {
throw new Error('JWT missing required claims')
}
debug('JWT verified successfully:', payload.sub)

return {
payload,
email: payload.email as string,
name: payload.name as string,
sub: payload.sub,
groups: (payload.groups as string[]) || [],
roles: (payload.roles as string[]) || [],
}
} catch (error: any) {
debug('JWT verification failed:', error.message)
throw error
}
}

export async function waitForJwksReady(): Promise<void> {
debug('Waiting for JWKS endpoint to become ready...')

await retry(
async () => {
try {
// Try a dummy verification with an invalid token
// We only care that JWKS fetch succeeds, not that the token is valid
const invalidJWT = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.invalid_signature'
await verifyJwt(invalidJWT)
} catch (err: any) {
// If we get a JWT verification error, JWKS successfully fetched and processed the token
// This means JWKS is working! (Even though token was rejected - that's expected)
const isJwtVerificationError =
err?.code?.startsWith('ERR_JW') || // jose library error codes (ERR_JWS_INVALID, ERR_JWT_INVALID, etc.)
err?.message?.includes('signature') ||
err?.message?.includes('invalid') ||
err?.message?.includes('expired') ||
err?.message?.includes('claim')

if (isJwtVerificationError) {
debug('JWKS endpoint reachable (token verification worked) — continuing startup.')
return
}

debug(`JWKS not ready yet: ${err.message}, retrying...`)
throw err
}
},
{
retries: env.STARTUP_RETRY_COUNT,
minTimeout: env.STARTUP_RETRY_INTERVAL_MS,
maxTimeout: env.STARTUP_RETRY_INTERVAL_MS,
},
)
}
Loading
Loading