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
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ curl -s -X POST $ENDPOINT/recorder/$ID/websocket -H 'accept: application/json' -
curl -s "$ENDPOINT/recorder/$ID/records?limit=10" | jq .data.tick
```

## Configuration
## General configuration

| Environment variable | Description | default value |
|--------------------------------------|--------------------------------------------|----------------------------|
Expand All @@ -79,6 +79,38 @@ curl -s "$ENDPOINT/recorder/$ID/records?limit=10" | jq .data.tick
| WEB_ECHO_WEBSOCKETS_MAX_DURATION | Maximum duration for websockets connection | 4h |
| WEB_ECHO_SHA_GOAL | Difficulty level for Proof of Work (0=off) | 0 |


## Security

By default, security is disabled. You can enable Keycloak authentication to protect recorder creation.

### Configuration

| Environment variable | Description | Default value |
|--------------------------------------|----------------------------------------------|-----------------------|
| WEB_ECHO_SECURITY_KEYCLOAK_ENABLED | Enable Keycloak authentication | false |
| WEB_ECHO_SECURITY_KEYCLOAK_URL | Keycloak server URL | "http://localhost:8081" |
| WEB_ECHO_SECURITY_KEYCLOAK_REALM | Keycloak realm | "web-echo" |
| WEB_ECHO_SECURITY_KEYCLOAK_RESOURCE | Keycloak client ID / resource | "web-echo" |

### Usage with Authentication

When security is enabled, you must provide a valid JWT token in the `Authorization` header to create a recorder.

```bash
# Get a token from Keycloak (example)
TOKEN=$(curl -s -X POST "http://localhost:8081/realms/web-echo/protocol/openid-connect/token" \
-d "client_id=web-echo" \
-d "username=your_user" \
-d "password=your_password" \
-d "grant_type=password" | jq -r .access_token)

# Create a recorder using the token
ID=$(curl -X POST $ENDPOINT/recorder \
-H "Authorization: Bearer $TOKEN" \
-H 'accept: application/json' | jq -r .id)
```

[scl]: https://scala-cli.virtuslab.org/

[webecho-api]: https://web-echo.code-examples.org/docs
Expand Down
2 changes: 2 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ libraryDependencies ++= Seq(

// server side dependencies
libraryDependencies ++= Seq(
"com.github.jwt-scala" %% "jwt-core" % "10.0.1",
"com.auth0" % "jwks-rsa" % "0.22.1",
"com.github.ben-manes.caffeine" % "caffeine" % versions.caffeine,
"io.scalaland" %% "chimney" % versions.chimney,
"com.softwaremill.sttp.tapir" %% "tapir-core" % versions.tapir,
Expand Down
37 changes: 37 additions & 0 deletions dev-resources/keycloak/web-echo-realm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"id": "web-echo",
"realm": "web-echo",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": true,
"clients": [
{
"clientId": "web-echo",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"redirectUris": [
"*"
],
"webOrigins": [
"*"
]
}
],
"users": [
{
"username": "user",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
]
}
]
}
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: web-echo-keycloak
command: start-dev --import-realm
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
volumes:
- ./dev-resources/keycloak/web-echo-realm.json:/opt/keycloak/data/import/realm.json
ports:
- "8081:8080"
18 changes: 18 additions & 0 deletions src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ web-echo {
storage-handle-ttl = ${?WEB_ECHO_STORAGE_HANDLE_TTL}
}

security {
keycloak {
enabled = false
enabled = ${?WEB_ECHO_SECURITY_KEYCLOAK_ENABLED}

# The base URL of your Keycloak server, e.g., http://localhost:8080/auth or https://keycloak.example.com
url = "http://localhost:8081"
url = ${?WEB_ECHO_SECURITY_KEYCLOAK_URL}

realm = "web-echo"
realm = ${?WEB_ECHO_SECURITY_KEYCLOAK_REALM}

# Optional: client-id / resource to verify against 'aud' or 'azp' claims
resource = "web-echo"
resource = ${?WEB_ECHO_SECURITY_KEYCLOAK_RESOURCE}
}
}

// ----------------------------------------------------------------
// pekko & pekko-http framework configuration
// This configuration is used when this project is used as an app and not as a lib
Expand Down
4 changes: 2 additions & 2 deletions src/main/scala/webecho/Service.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ case class Service(dependencies: ServiceDependencies, servicesRoutes: ServiceRou
private val logger: Logger = org.slf4j.LoggerFactory.getLogger(appCode)
logger.info(s"$appCode service version $version is starting")

val config = ConfigFactory.load() // akka specific config is accessible under the path named 'web-echo'
implicit val system: ActorSystem = org.apache.pekko.actor.ActorSystem(s"akka-http-$appCode-system", config.getConfig("web-echo"))
// System is now provided by dependencies
implicit val system: ActorSystem = dependencies.system
implicit val executionContext: ExecutionContextExecutor = system.dispatcher

val bindingFuture: Future[Http.ServerBinding] = Http().newServerAt(interface = interface, port = port).bindFlow(servicesRoutes.routes)
Expand Down
20 changes: 20 additions & 0 deletions src/main/scala/webecho/ServiceConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ case class Behavior(
storageHandleTtl: Duration
) derives ConfigReader

case class KeycloakConfig(
enabled: Boolean,
url: String,
realm: String,
resource: Option[String]
) derives ConfigReader {
// Helper to construct the JWKS URL
// Keycloak standard path: /realms/{realm}/protocol/openid-connect/certs
// Handling potential trailing slash in url
def jwksUrl: String = s"${url.stripSuffix("/")}/realms/$realm/protocol/openid-connect/certs"

// Helper to construct Issuer
def issuer: String = s"${url.stripSuffix("/")}/realms/$realm"
}

case class SecurityConfig(
keycloak: KeycloakConfig
) derives ConfigReader

// Automatically populated by the build process from a generated config file
case class WebEchoMetaConfig(
projectName: Option[String],
Expand All @@ -81,6 +100,7 @@ case class WebEchoConfig(
http: HttpConfig,
site: SiteConfig,
behavior: Behavior,
security: SecurityConfig,
metaInfo: WebEchoMetaConfig
) derives ConfigReader

Expand Down
12 changes: 12 additions & 0 deletions src/main/scala/webecho/ServiceDependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,35 @@ package webecho

import webecho.dependencies.echostore.{EchoStore, EchoStoreFileSystem, EchoStoreMemOnly}
import webecho.dependencies.websocketsbot.{BasicWebSocketsBot, WebSocketsBot}
import webecho.security.SecurityService
import scala.concurrent.ExecutionContext.Implicits.global
import org.apache.pekko.actor.ActorSystem
import com.typesafe.config.ConfigFactory

trait ServiceDependencies {
val config: ServiceConfig
val echoStore: EchoStore
val webSocketsBot: WebSocketsBot
val securityService: SecurityService
implicit val system: ActorSystem
}

object ServiceDependencies {
def defaults: ServiceDependencies = {
val selectedConfig = ServiceConfig()
val akkaConfig = ConfigFactory.load().getConfig("web-echo")
implicit val sys = ActorSystem(s"akka-http-${selectedConfig.webEcho.application.code}-system", akkaConfig)

// val selectedStore = EchoCacheMemOnly(selectedConfig)
val selectedStore = EchoStoreFileSystem(selectedConfig)
val security = new SecurityService(selectedConfig.webEcho.security)

new ServiceDependencies {
override val config: ServiceConfig = selectedConfig
override val echoStore: EchoStore = selectedStore
override val webSocketsBot: BasicWebSocketsBot = BasicWebSocketsBot(selectedConfig, selectedStore)
override val securityService: SecurityService = security
override implicit val system: ActorSystem = sys
}
}
}
2 changes: 2 additions & 0 deletions src/main/scala/webecho/routing/ApiEndpoints.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ object ApiEndpoints extends JsonSupport {

val recorderCreateEndpoint = recorderEndpoint
.summary("Create a recorder")
.securityIn(auth.bearer[String]())
.post
.in(userAgent)
.in(clientIp)
.out(jsonBody[ApiRecorder])
.errorOut(baseErrorOut)

val recorderGetEndpoint = recorderEndpoint
.summary("Get a recorder")
Expand Down
14 changes: 11 additions & 3 deletions src/main/scala/webecho/routing/ApiRoutes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,17 @@ case class ApiRoutes(dependencies: ServiceDependencies) extends DateTimeTools wi
)
}

private val recorderCreateLogic = recorderCreateEndpoint.serverLogic { case (userAgent, clientIP) =>
createRecorderLogic(userAgent, clientIP)
}
private val recorderCreateLogic = recorderCreateEndpoint
.serverSecurityLogic { token =>
dependencies.securityService.validate(token).map {
case Right(_) => Right(())
case Left(msg) => Left(ApiErrorForbidden(msg))
}
}
.serverLogic { _ => inputs =>
val (userAgent, clientIP) = inputs
createRecorderLogic(userAgent, clientIP)
}

private val recorderGetLogic = recorderGetEndpoint.serverLogic { uuid =>
echoStore.echoInfo(uuid) match {
Expand Down
Loading