diff --git a/README.md b/README.md index 215a019..556ea54 100644 --- a/README.md +++ b/README.md @@ -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 | |--------------------------------------|--------------------------------------------|----------------------------| @@ -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 diff --git a/build.sbt b/build.sbt index 2511597..78b6dd5 100644 --- a/build.sbt +++ b/build.sbt @@ -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, diff --git a/dev-resources/keycloak/web-echo-realm.json b/dev-resources/keycloak/web-echo-realm.json new file mode 100644 index 0000000..df1706a --- /dev/null +++ b/dev-resources/keycloak/web-echo-realm.json @@ -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 + } + ] + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5756637 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf index 55d89fb..f91ad4a 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -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 diff --git a/src/main/scala/webecho/Service.scala b/src/main/scala/webecho/Service.scala index ac7672f..a6b77c9 100644 --- a/src/main/scala/webecho/Service.scala +++ b/src/main/scala/webecho/Service.scala @@ -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) diff --git a/src/main/scala/webecho/ServiceConfig.scala b/src/main/scala/webecho/ServiceConfig.scala index 4a5e7e3..4b63470 100644 --- a/src/main/scala/webecho/ServiceConfig.scala +++ b/src/main/scala/webecho/ServiceConfig.scala @@ -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], @@ -81,6 +100,7 @@ case class WebEchoConfig( http: HttpConfig, site: SiteConfig, behavior: Behavior, + security: SecurityConfig, metaInfo: WebEchoMetaConfig ) derives ConfigReader diff --git a/src/main/scala/webecho/ServiceDependencies.scala b/src/main/scala/webecho/ServiceDependencies.scala index 4cfab2f..f6272e1 100644 --- a/src/main/scala/webecho/ServiceDependencies.scala +++ b/src/main/scala/webecho/ServiceDependencies.scala @@ -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 } } } diff --git a/src/main/scala/webecho/routing/ApiEndpoints.scala b/src/main/scala/webecho/routing/ApiEndpoints.scala index f16624a..ab3903e 100644 --- a/src/main/scala/webecho/routing/ApiEndpoints.scala +++ b/src/main/scala/webecho/routing/ApiEndpoints.scala @@ -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") diff --git a/src/main/scala/webecho/routing/ApiRoutes.scala b/src/main/scala/webecho/routing/ApiRoutes.scala index 1af7f3e..275e378 100644 --- a/src/main/scala/webecho/routing/ApiRoutes.scala +++ b/src/main/scala/webecho/routing/ApiRoutes.scala @@ -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 { diff --git a/src/main/scala/webecho/routing/HomeRouting.scala b/src/main/scala/webecho/routing/HomeRouting.scala index 6f08fed..60cabdb 100644 --- a/src/main/scala/webecho/routing/HomeRouting.scala +++ b/src/main/scala/webecho/routing/HomeRouting.scala @@ -10,73 +10,185 @@ import webecho.model.{StoreInfo, Origin} import webecho.templates.html.{HomeTemplate, RecorderTemplate, RecordedDataTemplate} import webecho.tools.{UniqueIdentifiers, QRCodeGenerator} import java.time.OffsetDateTime +import org.slf4j.LoggerFactory case class HomePageContext(page: PageContext, stats: StoreInfo) case class RecorderPageContext(page: PageContext, fullRecorderUrl: String, baseRecorderUrl: String, viewDataUrl: String, message: Option[String]) case class RecordedDataPageContext(page: PageContext, recorderPageUrl: String, dataApiUrl: String) +import org.apache.pekko.http.scaladsl.model.headers.HttpCookie + case class HomeRouting(dependencies: ServiceDependencies) extends Routing { - override def routes: Route = concat(home, createRecorder, showRecorder, showRecordedData, qrcode) + private val logger = LoggerFactory.getLogger(getClass) + override def routes: Route = concat(home, createRecorder, showRecorder, showRecordedData, qrcode, login, logout, callback) val site = dependencies.config.webEcho.site + val keycloak = dependencies.config.webEcho.security.keycloak + val clientId = keycloak.resource.getOrElse("web-echo") - val pageContext = PageContext(dependencies.config.webEcho) + private def getRedirectUri(uri: org.apache.pekko.http.scaladsl.model.Uri): String = { + val host = uri.authority.host.address() + val port = if (uri.authority.port != 0) s":${uri.authority.port}" else "" + val prefix = dependencies.config.webEcho.site.absolutePrefix + s"${uri.scheme}://$host$port$prefix/callback" + } + + // Helper to create context with login state + private def getPageContext(isLoggedIn: Boolean): PageContext = { + PageContext(dependencies.config.webEcho, isLoggedIn) + } def home: Route = pathEndOrSingleSlash { get { - complete { - val statsOption = dependencies.echoStore.storeInfo() - val stats = statsOption.getOrElse(StoreInfo(lastUpdated = None, count = 0)) - val homePageContext = HomePageContext(pageContext, stats) - val content = HomeTemplate.render(homePageContext).toString() - val contentType = `text/html` withCharset `UTF-8` - HttpResponse(entity = HttpEntity(contentType, content), headers = noClientCacheHeaders) + optionalCookie("X-Auth-Token") { cookie => + val isLoggedIn = cookie.exists(_.value.nonEmpty) + complete { + val statsOption = dependencies.echoStore.storeInfo() + val stats = statsOption.getOrElse(StoreInfo(lastUpdated = None, count = 0)) + val homePageContext = HomePageContext(getPageContext(isLoggedIn), stats) + val content = HomeTemplate.render(homePageContext).toString() + val contentType = `text/html` withCharset `UTF-8` + HttpResponse(entity = HttpEntity(contentType, content), headers = noClientCacheHeaders) + } + } + } + } + + def login: Route = path("login") { + get { + if (keycloak.enabled) { + extractUri { uri => + val currentRedirectUri = getRedirectUri(uri) + val encodedRedirectUri = java.net.URLEncoder.encode(currentRedirectUri, "UTF-8") + val authUrl = s"${keycloak.url.stripSuffix("/")}/realms/${keycloak.realm}/protocol/openid-connect/auth" + + s"?client_id=$clientId&response_type=code&redirect_uri=$encodedRedirectUri" + logger.debug(s"Login route hit. Redirecting to Keycloak: $authUrl") + redirect(authUrl, StatusCodes.SeeOther) + } + } else { + logger.debug("Login route hit. Security disabled. Redirecting to Home.") + redirect("/", StatusCodes.SeeOther) + } + } + } + + def logout: Route = path("logout") { + get { + deleteCookie("X-Auth-Token", path = "/") { + if (keycloak.enabled) { + extractUri { uri => + val host = uri.authority.host.address() + val port = if (uri.authority.port != 0) s":${uri.authority.port}" else "" + val prefix = dependencies.config.webEcho.site.absolutePrefix + val redirectUri = s"${uri.scheme}://$host$port$prefix/" + val encodedRedirectUri = java.net.URLEncoder.encode(redirectUri, "UTF-8") + + val logoutUrl = s"${keycloak.url.stripSuffix("/")}/realms/${keycloak.realm}/protocol/openid-connect/logout" + + s"?post_logout_redirect_uri=$encodedRedirectUri&client_id=$clientId" + + logger.debug(s"Logout route hit. Redirecting to Keycloak logout: $logoutUrl") + redirect(logoutUrl, StatusCodes.SeeOther) + } + } else { + logger.debug("Logout route hit. Security disabled. Redirecting to Home.") + redirect("/", StatusCodes.SeeOther) + } + } + } + } + + def callback: Route = path("callback") { + get { + parameters("code") { code => + extractUri { uri => + val currentRedirectUri = getRedirectUri(uri) + logger.debug("Callback hit with code.") + onSuccess(dependencies.securityService.exchangeCodeForToken(code, currentRedirectUri)) { + case Some(token) => + logger.debug("Token exchanged successfully.") + setCookie(HttpCookie("X-Auth-Token", value = token, path = Some("/"), httpOnly = true)) { + optionalCookie("Login-State") { stateCookie => + deleteCookie("Login-State", path = "/") { + if (stateCookie.map(_.value).contains("create")) { + logger.debug("Performing automatic recorder creation based on cookie.") + performCreateRecorder + } else { + logger.debug("Redirecting to Home.") + redirect("/", StatusCodes.Found) + } + } + } + } + case None => + logger.debug("Token exchange failed.") + complete(StatusCodes.Unauthorized, "Login failed") + } + } } } } def createRecorder: Route = path("recorder") { post { - extractClientIP { ip => - extractRequest { request => - val userAgent = request.headers.find(_.name() == "User-Agent").map(_.value()) - val uuid = UniqueIdentifiers.timedUUID() - val origin = Origin( - createdOn = OffsetDateTime.now(), - createdByIpAddress = ip.toOption.map(_.getHostAddress), - createdByUserAgent = userAgent - ) - dependencies.echoStore.echoAdd(uuid, Some(origin)) - // Redirect to the show page - redirect(s"${site.baseURL}/recorder/$uuid", StatusCodes.SeeOther) + optionalCookie("X-Auth-Token") { cookie => + val token = cookie.map(_.value).getOrElse("") + onSuccess(dependencies.securityService.validate(token)) { + case Right(_) => + logger.debug("createRecorder - Validation success.") + performCreateRecorder + case Left(msg) => + logger.debug(s"createRecorder - Validation failed ($msg). Setting intent cookie and redirecting to login.") + setCookie(HttpCookie("Login-State", "create", path = Some("/"), httpOnly = true)) { + redirect("/login", StatusCodes.SeeOther) + } } } } } + private def performCreateRecorder: Route = { + extractClientIP { ip => + extractRequest { request => + val userAgent = request.headers.find(_.name() == "User-Agent").map(_.value()) + val uuid = UniqueIdentifiers.timedUUID() + val origin = Origin( + createdOn = OffsetDateTime.now(), + createdByIpAddress = ip.toOption.map(_.getHostAddress), + createdByUserAgent = userAgent + ) + dependencies.echoStore.echoAdd(uuid, Some(origin)) + // Redirect to the show page + redirect(s"${site.baseURL}/recorder/$uuid", StatusCodes.SeeOther) + } + } + } + def showRecorder: Route = path("recorder" / Segment) { uuidStr => get { - parameter("message".?) { message => - UniqueIdentifiers.fromString(uuidStr) match { - case scala.util.Success(uuid) => - dependencies.echoStore.echoInfo(uuid) match { - case Some(_) => - val baseRecorderUrl = s"${site.apiURL}/recorder/$uuid" - val fullRecorderUrl = message.filter(_.nonEmpty) match { - case Some(msg) => s"$baseRecorderUrl?message=$msg" - case None => baseRecorderUrl - } - val viewDataUrl = s"${site.baseURL}/recorder/$uuid/view" + optionalCookie("X-Auth-Token") { cookie => + val isLoggedIn = cookie.exists(_.value.nonEmpty) + parameter("message".?) { message => + UniqueIdentifiers.fromString(uuidStr) match { + case scala.util.Success(uuid) => + dependencies.echoStore.echoInfo(uuid) match { + case Some(_) => + val baseRecorderUrl = s"${site.apiURL}/recorder/$uuid" + val fullRecorderUrl = message.filter(_.nonEmpty) match { + case Some(msg) => s"$baseRecorderUrl?message=$msg" + case None => baseRecorderUrl + } + val viewDataUrl = s"${site.baseURL}/recorder/$uuid/view" - val ctx = RecorderPageContext(pageContext, fullRecorderUrl, baseRecorderUrl, viewDataUrl, message) - val content = RecorderTemplate.render(ctx).toString() - val contentType = `text/html` withCharset `UTF-8` - complete(HttpResponse(entity = HttpEntity(contentType, content), headers = noClientCacheHeaders)) - case None => - complete(StatusCodes.NotFound, "Recorder not found") - } - case scala.util.Failure(_) => - complete(StatusCodes.BadRequest, "Invalid UUID") + val ctx = RecorderPageContext(getPageContext(isLoggedIn), fullRecorderUrl, baseRecorderUrl, viewDataUrl, message) + val content = RecorderTemplate.render(ctx).toString() + val contentType = `text/html` withCharset `UTF-8` + complete(HttpResponse(entity = HttpEntity(contentType, content), headers = noClientCacheHeaders)) + case None => + complete(StatusCodes.NotFound, "Recorder not found") + } + case scala.util.Failure(_) => + complete(StatusCodes.BadRequest, "Invalid UUID") + } } } } @@ -84,27 +196,30 @@ case class HomeRouting(dependencies: ServiceDependencies) extends Routing { def showRecordedData: Route = path("recorder" / Segment / "view") { uuidStr => get { - parameter("message".?) { message => - UniqueIdentifiers.fromString(uuidStr) match { - case scala.util.Success(uuid) => - dependencies.echoStore.echoInfo(uuid) match { - case Some(_) => - val baseRecorderPageUrl = s"${site.baseURL}/recorder/$uuid" - val recorderPageUrl = message.filter(_.nonEmpty) match { - case Some(msg) => s"$baseRecorderPageUrl?message=$msg" - case None => baseRecorderPageUrl - } + optionalCookie("X-Auth-Token") { cookie => + val isLoggedIn = cookie.exists(_.value.nonEmpty) + parameter("message".?) { message => + UniqueIdentifiers.fromString(uuidStr) match { + case scala.util.Success(uuid) => + dependencies.echoStore.echoInfo(uuid) match { + case Some(_) => + val baseRecorderPageUrl = s"${site.baseURL}/recorder/$uuid" + val recorderPageUrl = message.filter(_.nonEmpty) match { + case Some(msg) => s"$baseRecorderPageUrl?message=$msg" + case None => baseRecorderPageUrl + } - val dataApiUrl = s"${site.apiURL}/recorder/$uuid/records" - val ctx = RecordedDataPageContext(pageContext, recorderPageUrl, dataApiUrl) - val content = RecordedDataTemplate.render(ctx).toString() - val contentType = `text/html` withCharset `UTF-8` - complete(HttpResponse(entity = HttpEntity(contentType, content), headers = noClientCacheHeaders)) - case None => - complete(StatusCodes.NotFound, "Recorder not found") - } - case scala.util.Failure(_) => - complete(StatusCodes.BadRequest, "Invalid UUID") + val dataApiUrl = s"${site.apiURL}/recorder/$uuid/records" + val ctx = RecordedDataPageContext(getPageContext(isLoggedIn), recorderPageUrl, dataApiUrl) + val content = RecordedDataTemplate.render(ctx).toString() + val contentType = `text/html` withCharset `UTF-8` + complete(HttpResponse(entity = HttpEntity(contentType, content), headers = noClientCacheHeaders)) + case None => + complete(StatusCodes.NotFound, "Recorder not found") + } + case scala.util.Failure(_) => + complete(StatusCodes.BadRequest, "Invalid UUID") + } } } } diff --git a/src/main/scala/webecho/routing/PageContext.scala b/src/main/scala/webecho/routing/PageContext.scala index ad251c4..9e76e02 100644 --- a/src/main/scala/webecho/routing/PageContext.scala +++ b/src/main/scala/webecho/routing/PageContext.scala @@ -30,11 +30,13 @@ case class PageContext( projectURL: String, buildVersion: String, buildDateTime: String, - contactEmail: String + contactEmail: String, + securityEnabled: Boolean, + isLoggedIn: Boolean ) object PageContext { - def apply(config: WebEchoConfig) = { + def apply(config: WebEchoConfig, isLoggedIn: Boolean = false) = { val site = config.site new PageContext( title = config.application.name, @@ -48,7 +50,9 @@ object PageContext { projectURL = config.metaInfo.projectURL, buildVersion = config.metaInfo.version, buildDateTime = config.metaInfo.dateTime, - contactEmail = config.metaInfo.contact + contactEmail = config.metaInfo.contact, + securityEnabled = config.security.keycloak.enabled, + isLoggedIn = isLoggedIn ) } } diff --git a/src/main/scala/webecho/security/SecurityService.scala b/src/main/scala/webecho/security/SecurityService.scala new file mode 100644 index 0000000..4fba938 --- /dev/null +++ b/src/main/scala/webecho/security/SecurityService.scala @@ -0,0 +1,137 @@ +package webecho.security + +import com.auth0.jwk.{JwkProvider, JwkProviderBuilder} +import pdi.jwt.{Jwt, JwtAlgorithm, JwtOptions} +import webecho.SecurityConfig +import java.net.URL +import scala.concurrent.{Future, ExecutionContext} +import scala.util.{Try, Success, Failure} +import java.util.Base64 +import java.nio.charset.StandardCharsets + +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.http.scaladsl.Http +import org.apache.pekko.http.scaladsl.model._ +import org.apache.pekko.http.scaladsl.unmarshalling.Unmarshal +import webecho.tools.JsonSupport +import org.slf4j.LoggerFactory +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ + +case class TokenResponse(access_token: String) + +class SecurityService(config: SecurityConfig)(implicit system: ActorSystem) extends JsonSupport { + import system.dispatcher + private val logger = LoggerFactory.getLogger(getClass) + + implicit val tokenResponseCodec: JsonValueCodec[TokenResponse] = JsonCodecMaker.make + + private val provider: Option[JwkProvider] = if (config.keycloak.enabled) { + Try(new URL(config.keycloak.jwksUrl)).toOption.map { + url => + new JwkProviderBuilder(url) + .cached(10, 24, java.util.concurrent.TimeUnit.HOURS) + .build() + } + } else { + None + } + + def validate(token: String): Future[Either[String, Unit]] = { + if (config.keycloak.enabled && provider.isDefined) { + validateJwt(token) + } else { + if (!config.keycloak.enabled) { + Future.successful(Right(())) + } else { + Future.successful(Left("Authentication failed")) + } + } + } + + def exchangeCodeForToken(code: String, redirectUri: String): Future[Option[String]] = { + if (!config.keycloak.enabled) return Future.successful(None) + + val tokenUrl = s"${config.keycloak.url.stripSuffix("/")}/realms/${config.keycloak.realm}/protocol/openid-connect/token" + val clientId = config.keycloak.resource.getOrElse("web-echo") + + val formData = FormData( + "grant_type" -> "authorization_code", + "client_id" -> clientId, + "code" -> code, + "redirect_uri" -> redirectUri + ).toEntity + + val request = HttpRequest( + method = HttpMethods.POST, + uri = tokenUrl, + entity = formData + ) + + Http().singleRequest(request).flatMap { response => + if (response.status == StatusCodes.OK) { + Unmarshal(response.entity).to[String].map { body => + Try(readFromString[TokenResponse](body).access_token).toOption + } + } else { + response.discardEntityBytes() + Future.successful(None) + } + } + } + + private def getKid(token: String): Try[String] = { + Try { + val parts = token.split("\\.") + if (parts.length < 2) throw new Exception("Invalid JWT format") + val headerJson = new String(java.util.Base64.getUrlDecoder.decode(parts(0)), StandardCharsets.UTF_8) + val headerMap = readFromString[Map[String, Any]](headerJson)(mapAnyCodec) + headerMap.getOrElse("kid", throw new Exception("No 'kid' in header")).toString + } + } + + private def validateJwt(token: String): Future[Either[String, Unit]] = { + Future { + val result = for { + kid <- getKid(token) + jwk <- Try(provider.get.get(kid)) + publicKey <- Try(jwk.getPublicKey) + options = JwtOptions(signature = true, expiration = true, notBefore = true) + + // Decode and verify signature + claim <- Jwt.decode(token, publicKey, Seq(JwtAlgorithm.RS256), options) + + // Verify Issuer (Relaxed check for local dev/docker compatibility) + _ <- if (claim.issuer.contains(config.keycloak.issuer)) { + Success(()) + } else { + // Just warn instead of failing, to handle localhost vs 127.0.0.1 mismatches + logger.warn(s"Issuer mismatch. Expected: ${config.keycloak.issuer}, Got: ${claim.issuer.getOrElse("")}") + Success(()) + } + + // Verify Audience / Resource (Optional) + // Keycloak puts the client_id in 'azp' (Authorized Party) or 'aud' + // If config.keycloak.resource is set, we check if it is present in aud or azp (if claims supported) + // jwt-core Claim access is a bit limited on specific fields like azp, + // but we can check the JSON content or standard fields. + // For now, let's check standard 'aud'. + _ <- config.keycloak.resource match { + case Some(res) => + if (claim.audience.exists(_.contains(res))) Success(()) + // You might also want to check 'azp' claim if 'aud' check fails, + // but jwt-core might not expose azp directly in JwtClaim class comfortably without raw json. + // Let's rely on Audience for now as it's standard. + else Failure(new Exception(s"Invalid audience. Expected: $res")) + case None => Success(()) + } + + } yield () + + result match { + case Success(_) => Right(()) + case Failure(e) => Left(s"JWT Validation failed: ${e.getMessage}") + } + } + } +} \ No newline at end of file diff --git a/src/main/twirl/webecho/templates/HomeTemplate.scala.html b/src/main/twirl/webecho/templates/HomeTemplate.scala.html index 4ec63d5..e714aa3 100644 --- a/src/main/twirl/webecho/templates/HomeTemplate.scala.html +++ b/src/main/twirl/webecho/templates/HomeTemplate.scala.html @@ -11,7 +11,16 @@
@context.page.title