Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ data class MiddlewareAuthHeader(

return MiddlewareAuthHeader(type, token)
}

fun fromString(value: String): MiddlewareAuthHeader {
val parts = value.split(" ", limit = 2)
if (parts.size == 2) {
val type = MiddlewareAuthHeaderType.entries.firstOrNull {
it != MiddlewareAuthHeaderType.None && it.name.equals(parts[0], ignoreCase = true)
}
if (type != null) {
return MiddlewareAuthHeader(type = type, token = parts[1])
}
}
return MiddlewareAuthHeader(type = MiddlewareAuthHeaderType.None, token = value)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.lb.common.data.service

import io.lb.common.data.model.MappedResponse
import io.lb.common.data.model.MappedRoute
import io.lb.common.data.request.MiddlewareAuthHeader
import io.lb.common.shared.error.MiddlewareException

/**
Expand All @@ -10,9 +11,10 @@ import io.lb.common.shared.error.MiddlewareException
interface ServerService {
/**
* Starts the generic mapping route.
* @param onReceive The handler for the generic mapping route. It receives the mapped route and returns the URL.
* @param onReceive The handler for the generic mapping route. It receives the mapped route,
* an optional runtime auth header (from X-Mapped-Auth, not stored), and returns the URL.
*/
fun startGenericMappingRoute(onReceive: suspend (MappedRoute) -> String)
fun startGenericMappingRoute(onReceive: suspend (MappedRoute, MiddlewareAuthHeader?) -> String)

/**
* Starts the query all routes route.
Expand Down
26 changes: 24 additions & 2 deletions documentation/MappingRequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ You can also include the following optional fields in the root of your mapping r

### External API Authentication:

To authenticate calls to the original external API, include an `authHeader` field inside `originalRoute`. This is independent from the Middleware's own authentication (handled via the `Authorization` header when calling the Middleware).
There are two ways to authenticate calls to the original external API. Both are independent from the Middleware's own authentication (handled via the `Authorization` header when calling the Middleware).

#### Option 1 — Pre-configured auth (stored with the route)

Include an `authHeader` field inside `originalRoute` when creating the route. The token is stored and automatically used on every request to that mapped route.

Supported auth types:
- **`None`** — No authentication header is sent to the external API.
Expand All @@ -37,7 +41,25 @@ Supported auth types:
}
```

> **Note:** The Middleware's own `Authorization` header (used by clients to authenticate with the Middleware) is never forwarded to the external API. Only `authHeader` inside `originalRoute` controls external API authentication.
#### Option 2 — Runtime auth via `X-Mapped-Auth` header (not stored)

If you do not want to store the auth token in the route, omit `authHeader` from `originalRoute` and pass the `X-Mapped-Auth` header instead. This is useful when tokens are short-lived or user-specific.

The header value should be the full authorization string (same format as an `Authorization` header):

```
X-Mapped-Auth: Bearer your-api-token-here
X-Mapped-Auth: Basic dXNlcjpwYXNz
X-Mapped-Auth: raw-token
```

**When creating a route (`POST /v1/mapping`):** If `X-Mapped-Auth` is present and the route has no `authHeader`, the middleware uses it to validate the route against the external API before saving. The token is **not** stored — every subsequent call to that route must include `X-Mapped-Auth`.

**When calling a mapped route (`/v1/{uuid}/path`):** If the route has no pre-configured `authHeader`, the middleware uses `X-Mapped-Auth` from the incoming request to authenticate the outbound call to the external API.

**Priority:** Pre-configured `authHeader` always takes precedence over `X-Mapped-Auth`. If both are present, `X-Mapped-Auth` is ignored.

> **Note:** The Middleware's own `Authorization` header (used by clients to authenticate with the Middleware) is never forwarded to the external API. `X-Mapped-Auth` is also never forwarded as-is — it is only used internally to set the outbound `Authorization` header.

### Example Additional Fields:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import io.ktor.util.pipeline.PipelineContext
import io.ktor.util.toMap
import io.lb.common.data.model.MappedResponse
import io.lb.common.data.model.MappedRoute
import io.lb.common.data.request.MiddlewareAuthHeader
import io.lb.common.data.request.MiddlewareHttpMethods
import io.lb.common.data.service.ServerService
import io.lb.common.shared.error.MiddlewareException
Expand All @@ -37,16 +38,21 @@ import kotlinx.serialization.json.JsonObject
internal class ServerServiceImpl(
private val engine: NettyApplicationEngine
) : ServerService {
override fun startGenericMappingRoute(onReceive: suspend (MappedRoute) -> String) {
companion object {
const val MAPPED_AUTH_HEADER = "X-Mapped-Auth"
}
override fun startGenericMappingRoute(onReceive: suspend (MappedRoute, MiddlewareAuthHeader?) -> String) {
engine.application.routing {
authenticate {
post("v1/mapping") {
val parameter = call.receiveNullable<MappedRouteParameter>() ?: run {
call.respond(HttpStatusCode.BadRequest)
return@post
}
val runtimeAuthHeader = call.request.headers[MAPPED_AUTH_HEADER]
?.let { MiddlewareAuthHeader.fromString(it) }
val mappedRouteUrl = runCatching {
onReceive(parameter.toMappedRoute())
onReceive(parameter.toMappedRoute(), runtimeAuthHeader)
}.getOrElse { error ->
when (error) {
is IllegalArgumentException -> call.respond(
Expand Down Expand Up @@ -188,9 +194,11 @@ internal class ServerServiceImpl(
val queries = call.request.queryParameters.toMap().mapValues { query ->
query.value.first()
}
val runtimeAuthHeader = call.request.headers[MAPPED_AUTH_HEADER]
?.let { MiddlewareAuthHeader.fromString(it) }
val headers = call.request.headers.toMap()
.mapValues { it.value.first() }
.filterKeys { it != HttpHeaders.Authorization }
.filterKeys { it != HttpHeaders.Authorization && it != MAPPED_AUTH_HEADER }
val body = try {
call.receiveNullable<JsonObject>()
} catch (e: Exception) {
Expand All @@ -199,7 +207,8 @@ internal class ServerServiceImpl(
mappedRoute.originalRoute = mappedRoute.originalRoute.copy(
queries = queries,
headers = headers,
body = body
body = body,
authHeader = mappedRoute.originalRoute.authHeader ?: runtimeAuthHeader
)
val response = onRequest(mappedRoute)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.lb.impl.ktor.server.request

import io.lb.common.data.request.MiddlewareAuthHeader
import io.lb.common.data.request.MiddlewareAuthHeaderType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class MiddlewareAuthHeaderTest {
@Test
fun `fromString with Bearer prefix returns Bearer type and stripped token`() {
val result = MiddlewareAuthHeader.fromString("Bearer mytoken123")
assertEquals(MiddlewareAuthHeaderType.Bearer, result.type)
assertEquals("mytoken123", result.token)
assertEquals("Bearer mytoken123", result.fullToken())
}

@Test
fun `fromString with Basic prefix returns Basic type and stripped token`() {
val result = MiddlewareAuthHeader.fromString("Basic dXNlcjpwYXNz")
assertEquals(MiddlewareAuthHeaderType.Basic, result.type)
assertEquals("dXNlcjpwYXNz", result.token)
assertEquals("Basic dXNlcjpwYXNz", result.fullToken())
}

@Test
fun `fromString with lowercase bearer prefix returns Bearer type`() {
val result = MiddlewareAuthHeader.fromString("bearer mytoken")
assertEquals(MiddlewareAuthHeaderType.Bearer, result.type)
assertEquals("mytoken", result.token)
}

@Test
fun `fromString with raw token and no prefix returns None type`() {
val result = MiddlewareAuthHeader.fromString("rawtoken")
assertEquals(MiddlewareAuthHeaderType.None, result.type)
assertEquals("rawtoken", result.token)
assertEquals("rawtoken", result.fullToken())
}

@Test
fun `fromString with unknown prefix returns None type with full value as token`() {
val result = MiddlewareAuthHeader.fromString("Digest somecredentials")
assertEquals(MiddlewareAuthHeaderType.None, result.type)
assertEquals("Digest somecredentials", result.token)
}

@Test
fun `fromString with Bearer and multi-word token preserves full token`() {
val result = MiddlewareAuthHeader.fromString("Bearer a.b.c")
assertEquals(MiddlewareAuthHeaderType.Bearer, result.type)
assertEquals("a.b.c", result.token)
}
}
Loading
Loading