Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5abf42d
Update services
AmirRajabii Dec 10, 2025
7958e1f
Merge branch 'dev' of https://github.com/opexdev/core into update-log…
fatemeh-i Dec 12, 2025
8c8e1f2
Drop address regx column from chain table
fatemeh-i Dec 13, 2025
476e483
Separate user services
fatemeh-i Dec 15, 2025
d3a5a11
Set allowed audience for resources
fatemeh-i Dec 16, 2025
abd24c8
Check audience and issuer in any security configuration
fatemeh-i Dec 16, 2025
ae21d0c
Merge branch 'dev' of https://github.com/opexdev/core into update-log…
fatemeh-i Dec 17, 2025
c8ab94e
Read the token issuer url from the env
fatemeh-i Dec 17, 2025
569ff5a
Develop resend otp in login flow
fatemeh-i Dec 21, 2025
51e749b
Merge branch 'dev' of https://github.com/opexdev/core into update-log…
fatemeh-i Dec 21, 2025
e409909
Replace the exchange approach with bootstrap grant type in login flow
fatemeh-i Dec 23, 2025
55f87b5
Merge branch 'dev' of https://github.com/opexdev/core into update-log…
fatemeh-i Dec 23, 2025
4746ba7
Send login event in direct grant
fatemeh-i Dec 23, 2025
070c3a7
Remove offline access scope in normal access token
fatemeh-i Dec 24, 2025
5ae59cc
Merge branch 'update-login-flow' of https://github.com/opexdev/core i…
fatemeh-i Dec 24, 2025
7392454
Merge branch 'dev' of https://github.com/opexdev/core into update-log…
fatemeh-i Dec 24, 2025
72a2a41
Change the request authentication flow in the flow of thirdparty Api …
fatemeh-i Dec 28, 2025
e0e0cbf
Merge branch 'dev' of https://github.com/opexdev/core into update-log…
fatemeh-i Dec 28, 2025
722171b
Adjust authorization in api controller
fatemeh-i Dec 28, 2025
a8319b3
Read the api crypto key from the valut
fatemeh-i Dec 28, 2025
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
Original file line number Diff line number Diff line change
@@ -1,56 +1,148 @@
package co.nilin.opex.api.app.controller

import co.nilin.opex.api.app.data.APIKeyResponse
import co.nilin.opex.api.app.data.CreateAPIKeyRequest
import co.nilin.opex.api.app.service.APIKeyServiceImpl
import co.nilin.opex.common.security.jwtAuthentication
import org.springframework.security.core.annotation.CurrentSecurityContext
import org.springframework.security.core.context.SecurityContext
import co.nilin.opex.api.app.data.ApiKeyResponse
import co.nilin.opex.api.app.data.CreateApiKeyRequest
import co.nilin.opex.api.app.data.UpdateApiKeyRequest
import co.nilin.opex.common.security.JwtUtils
import org.springframework.web.bind.annotation.*
import java.security.Principal
import java.security.SecureRandom
import java.util.*

@RestController
@RequestMapping("/v1/api-key")
class APIKeyController(private val apiKeyService: APIKeyServiceImpl) {
class APIKeyController(
private val apiKeyService: co.nilin.opex.api.core.spi.APIKeyService
) {

@GetMapping
suspend fun getKeys(principal: Principal): List<APIKeyResponse> {
return apiKeyService.getKeysByUserId(principal.name)
.map { APIKeyResponse(it.label, it.expirationTime, it.allowedIPs, it.key, it.isEnabled) }
private val rng = SecureRandom()

private fun generateSecretBase64(bytes: Int = 48): String {
val b = ByteArray(bytes)
rng.nextBytes(b)
return Base64.getEncoder().encodeToString(b)
}

private fun canonicalTemplate(): String = "METHOD\nPATH\nQUERY\nBODY_SHA256\nTIMESTAMP_MS"

private fun headersTemplate(apiKeyId: String): Map<String, String> = mapOf(
"X-API-KEY" to apiKeyId,
"X-API-SIGNATURE" to "Base64(HMAC-SHA256(secret, canonical))",
"X-API-TIMESTAMP" to "<epoch_ms>",
"X-API-BODY-SHA256" to "<hex_sha256_body> (optional)"
)

// Create a new API key. Caller must provide a user access token; we bind the key to that user. Returns one-time secret and usage hints.
@PostMapping
suspend fun create(
@RequestBody request: CreateAPIKeyRequest,
@CurrentSecurityContext securityContext: SecurityContext
): Any {
val jwt = securityContext.jwtAuthentication()
val response = apiKeyService.createAPIKey(
jwt.name,
request.label,
request.expiration?.getLocalDateTime(),
request.allowedIPs,
jwt.token.tokenValue
@RequestHeader(name = "Authorization", required = false) authorization: String?,
@RequestBody req: CreateApiKeyRequest
): ApiKeyResponse {
require(!authorization.isNullOrBlank() && authorization.startsWith("Bearer ")) { "Authorization Bearer user token is required" }
val userToken = authorization.substringAfter("Bearer ").trim()
val (userId, preferredUsername) = parseJwtUser(userToken)

val apiKeyId = req.apiKeyId?.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString()
val secret = generateSecretBase64()
val stored = apiKeyService.createApiKeyRecord(
apiKeyId = apiKeyId,
label = req.label,
plaintextSecret = secret,
allowedIps = req.allowedIps,
allowedEndpoints = req.allowedEndpoints,
keycloakUserId = userId,
keycloakUsername = preferredUsername,
enabled = true
)
return ApiKeyResponse(
apiKeyId = apiKeyId,
label = stored.record.label,
enabled = stored.record.enabled,
allowedIps = stored.record.allowedIps,
allowedEndpoints = stored.record.allowedEndpoints,
keycloakUsername = stored.record.keycloakUsername,
secret = secret
)
return object {
val apiKey = response.second.key
val secret = response.first
}
}

@PutMapping("/{key}/enable")
suspend fun enableKey(principal: Principal, @PathVariable key: String) {
apiKeyService.changeKeyState(principal.name, key, true)
private fun parseJwtUser(token: String): Pair<String, String?> {
// Decode JWT payload using common JwtUtils (no signature verification here).
val payload = JwtUtils.decodePayload(token)
val sub = payload["sub"] as? String
val preferred = payload["username"] as? String
require(!sub.isNullOrBlank()) { "JWT missing sub" }
return Pair(sub!!, preferred)
}

@PutMapping("/{key}/disable")
suspend fun disableKey(principal: Principal, @PathVariable key: String) {
apiKeyService.changeKeyState(principal.name, key, false)
// List all API keys (admin-only) — secret is not returned
@GetMapping
suspend fun list(): List<ApiKeyResponse> = apiKeyService.listApiKeyRecords().stream().map {
ApiKeyResponse(
apiKeyId = it.apiKeyId,
label = it.label,
enabled = it.enabled,
allowedIps = it.allowedIps,
allowedEndpoints = it.allowedEndpoints,
keycloakUsername = it.keycloakUsername,
secret = null
)
}.toList()


// Get one API key (admin-only) — secret is not returned
@GetMapping("/{apiKeyId}")
suspend fun get(@PathVariable apiKeyId: String): ApiKeyResponse {
val it = apiKeyService.getApiKeyRecord(apiKeyId) ?: throw NoSuchElementException("API key not found: $apiKeyId")
return ApiKeyResponse(
apiKeyId = it.apiKeyId,
label = it.label,
enabled = it.enabled,
allowedIps = it.allowedIps,
allowedEndpoints = it.allowedEndpoints,
keycloakUsername = it.keycloakUsername,
secret = null
)
}

// Rotate secret (admin-only). Returns new one-time secret
@PostMapping("/{apiKeyId}/rotate")
suspend fun rotate(@PathVariable apiKeyId: String): ApiKeyResponse {
val newSecret = generateSecretBase64()
val stored = apiKeyService.rotateApiKeySecret(apiKeyId, newSecret)
return ApiKeyResponse(
apiKeyId = stored.record.apiKeyId,
label = stored.record.label,
enabled = stored.record.enabled,
allowedIps = stored.record.allowedIps,
allowedEndpoints = stored.record.allowedEndpoints,
keycloakUsername = stored.record.keycloakUserId,
secret = newSecret
)
}

@DeleteMapping("/{key}")
suspend fun deleteKey(principal: Principal, @PathVariable key: String) {
apiKeyService.deleteKey(principal.name, key)
// Update metadata or enable/disable (admin-only)
@PutMapping("/{apiKeyId}")
suspend fun update(@PathVariable apiKeyId: String, @RequestBody req: UpdateApiKeyRequest): ApiKeyResponse {
val s = apiKeyService.updateApiKeyRecord(
apiKeyId = apiKeyId,
label = req.label,
enabled = req.enabled,
allowedIps = req.allowedIps,
allowedEndpoints = req.allowedEndpoints,
keycloakUsername = req.keycloakUsername
)
return ApiKeyResponse(
apiKeyId = s.apiKeyId,
label = s.label,
enabled = s.enabled,
allowedIps = s.allowedIps,
allowedEndpoints = s.allowedEndpoints,
keycloakUsername = s.keycloakUserId
)
}

// Delete/revoke (admin-only)
@DeleteMapping("/{apiKeyId}")
suspend fun delete(@PathVariable apiKeyId: String) {
apiKeyService.deleteApiKeyRecord(apiKeyId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package co.nilin.opex.api.app.data

import java.time.LocalDateTime

data class APIKeyResponse(
val label: String,
val expirationTime: LocalDateTime?,
val allowedIPs: String?,
val key: String,
val enabled: Boolean
data class ApiKeyResponse(
val apiKeyId: String,
val label: String?,
val enabled: Boolean,
val allowedIps: Set<String>?,
val allowedEndpoints: Set<String>?,
val keycloakUsername: String?,
val secret: String? = null, // only present on create/rotate
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ package co.nilin.opex.api.app.data

data class AccessTokenResponse(
val access_token: String,
val refresh_token: String,
val refresh_token: String?,
val expires_in: Long
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package co.nilin.opex.api.app.data

data class CreateAPIKeyRequest(
val label: String,
val expiration: APIKeyExpiration?,
val allowedIPs: String?
data class CreateApiKeyRequest(
val apiKeyId: String?,
val label: String?,
val allowedIps: Set<String>?,
val allowedEndpoints: Set<String>?,
val keycloakUsername: String?
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package co.nilin.opex.api.app.data



data class UpdateApiKeyRequest(
val label: String?,
val enabled: Boolean?,
val allowedIps: Set<String>?,
val allowedEndpoints: Set<String>?,
val keycloakUsername: String?
)


Original file line number Diff line number Diff line change
@@ -1,40 +1,129 @@
package co.nilin.opex.api.app.interceptor

import co.nilin.opex.api.app.service.APIKeyServiceImpl
import co.nilin.opex.api.app.security.ClientCredentialsTokenService
import co.nilin.opex.api.app.security.HmacVerifier
import co.nilin.opex.api.core.spi.APIKeyService
import co.nilin.opex.api.core.spi.APIKeyFilter
import kotlinx.coroutines.reactor.mono
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono

@Component
class APIKeyFilterImpl(private val apiKeyService: APIKeyServiceImpl) : APIKeyFilter, WebFilter {
class APIKeyFilterImpl(
private val apiKeyService: APIKeyService,
private val hmacVerifier: HmacVerifier,
private val clientTokenService: ClientCredentialsTokenService
) : APIKeyFilter, WebFilter {

override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
private val logger = LoggerFactory.getLogger(APIKeyFilterImpl::class.java)

override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val request = exchange.request
val key = request.headers["X-API-KEY"]
val secret = request.headers["X-API-SECRET"]

if (key.isNullOrEmpty() || secret.isNullOrEmpty()) {
val apiKeyId = request.headers["X-API-KEY"]?.firstOrNull()
val signature = request.headers["X-API-SIGNATURE"]?.firstOrNull()
val tsHeader = request.headers["X-API-TIMESTAMP"]?.firstOrNull()
val uri = request.uri

// HMAC path when signature present
if (!apiKeyId.isNullOrBlank() && !signature.isNullOrBlank() && !tsHeader.isNullOrBlank()) {
return mono {
val entry = apiKeyService.getApiKeyForVerification(apiKeyId)
if (entry == null || !entry.enabled) {
logger.warn("Unknown or disabled API key: {}", apiKeyId)
null
} else {
// Optional IP allowlist
val sourceIp = request.remoteAddress?.address?.hostAddress
if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) {
logger.warn("API key {} request from disallowed IP {}", apiKeyId, sourceIp)
null
}
if (!entry.allowedEndpoints.isNullOrEmpty() && ( !entry.allowedEndpoints!!.contains(uri.rawPath))) {
logger.warn("API key {} request to unauthorized resource {}", apiKeyId, uri.rawPath)
null
} else {
val ts = tsHeader.toLongOrNull()
val bodyHash = request.headers["X-API-BODY-SHA256"]?.firstOrNull()
if (ts == null) {
logger.warn("Invalid timestamp header for bot {}", apiKeyId)
null
} else {
val ok = hmacVerifier.verify(
entry.secret,
signature,
HmacVerifier.VerificationInput(
method = request.method.name(),
path = uri.rawPath,
query = uri.rawQuery,
timestampMillis = ts,
bodySha256 = bodyHash
)
)
if (!ok) {
logger.warn("Invalid signature for apiKey {}", apiKeyId)
null
} else {
val userId = entry.keycloakUserId
if (userId.isNullOrBlank()) {
logger.warn("API key {} has no mapped Keycloak userId; rejecting", apiKeyId)
null
} else {
val bearer = clientTokenService.exchangeToUserToken(userId)
val req = request.mutate()
.header("Authorization", "Bearer $bearer")
.build()
exchange.mutate().request(req).build()
}
}
}
}
}
}.flatMap { updatedExchange ->
if (updatedExchange != null) chain.filter(updatedExchange) else chain.filter(exchange)
}
}

// Secret-only path with X-API-SECRET (kept as requested). We validate the provided secret
// against the stored HMAC secret for the apiKey, then proceed to exchange to the mapped user token.
val legacySecret = request.headers["X-API-SECRET"]?.firstOrNull()
if (apiKeyId.isNullOrBlank() || legacySecret.isNullOrBlank()) {
return chain.filter(exchange)
}
return mono {
val apiKey = apiKeyService.getAPIKey(key[0], secret[0])
if (apiKey != null && apiKey.isEnabled && apiKey.accessToken != null && !apiKey.isExpired) {
val req = exchange.request.mutate()
.header("Authorization", "Bearer ${apiKey.accessToken}")
.build()
exchange.mutate().request(req).build()
} else null
val entry = apiKeyService.getApiKeyForVerification(apiKeyId)
if (entry == null || !entry.enabled) {
logger.warn("Unknown or disabled API key on secret path: {}", apiKeyId)
null
} else {
// Optional IP allowlist
val sourceIp = request.remoteAddress?.address?.hostAddress
if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) {
logger.warn("API key {} request from disallowed IP {} (secret path)", apiKeyId, sourceIp)
null
} else if (legacySecret != entry.secret) {
logger.warn("Invalid X-API-SECRET for apiKey {}", apiKeyId)
null
} else {
val userId = entry.keycloakUserId
if (userId.isNullOrBlank()) {
logger.warn("API key {} has no mapped Keycloak userId; rejecting (secret path)", apiKeyId)
null
} else {
val bearer = clientTokenService.exchangeToUserToken(userId)
val req = request.mutate()
.header("Authorization", "Bearer $bearer")
.build()
exchange.mutate().request(req).build()
}
}
}
}.flatMap { updatedExchange ->
if (updatedExchange != null)
chain.filter(updatedExchange)
else
chain.filter(exchange)
if (updatedExchange != null) chain.filter(updatedExchange) else chain.filter(exchange)
}
}
}
Loading
Loading