Support JWT

This commit is contained in:
Paul-Henri Froidmont 2025-11-04 13:42:19 +01:00
parent 25318cd6de
commit aa9d60e4d1
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
9 changed files with 96 additions and 19 deletions

View file

@ -16,6 +16,8 @@ import zio.logging.LogFormat.*
import zio.logging.consoleLogger import zio.logging.consoleLogger
import zio.schema.codec.JsonCodec.ExplicitConfig import zio.schema.codec.JsonCodec.ExplicitConfig
import lu.foyer.auth.AuthMiddleware.jwtAuthentication
import lu.foyer.auth.JwtScalaTokenService
import lu.foyer.clients.* import lu.foyer.clients.*
import lu.foyer.contracts.* import lu.foyer.contracts.*
@ -29,6 +31,7 @@ object HttpServer:
contract <- ZIO.service[ContractController] contract <- ZIO.service[ContractController]
openAPI = OpenAPIGen.fromEndpoints(client.endpoints ++ contract.endpoints) openAPI = OpenAPIGen.fromEndpoints(client.endpoints ++ contract.endpoints)
yield (client.routes ++ contract.routes) yield (client.routes ++ contract.routes)
@@ jwtAuthentication("Insurance")
@@ cors(corsConfig) @@ Middleware.debug @@ cors(corsConfig) @@ Middleware.debug
++ SwaggerUI.routes("docs" / "openapi", openAPI) ++ SwaggerUI.routes("docs" / "openapi", openAPI)
@ -52,10 +55,12 @@ object App extends ZIOAppDefault:
val app = val app =
for for
routes <- HttpServer.routes routes <- HttpServer.routes
server <- Server.serve(routes).provide(Server.default) server <- Server.serve(routes)
yield server yield server
override def run = app.provide( override def run = app.provide(
Server.default,
JwtScalaTokenService.live,
CommandEngine.layer[ClientCommand, ClientEvent, ClientState], CommandEngine.layer[ClientCommand, ClientEvent, ClientState],
CommandEngine.layer[ContractCommand, ContractEvent, ContractState], CommandEngine.layer[ContractCommand, ContractEvent, ContractState],
ClientHandlers.layer, ClientHandlers.layer,

View file

@ -7,6 +7,8 @@ import zio.http.codec.PathCodec.path
import zio.http.endpoint.* import zio.http.endpoint.*
import zio.schema.* import zio.schema.*
import lu.foyer.auth.UserInfo
trait CommandEngineController[Command, Event: Schema, State: Schema] extends JsonApiController: trait CommandEngineController[Command, Event: Schema, State: Schema] extends JsonApiController:
def commandEngine: CommandEngine[Command, Event, State] def commandEngine: CommandEngine[Command, Event, State]

View file

@ -8,6 +8,7 @@ import zio.schema.*
import lu.foyer.JsonApiResponse.Many import lu.foyer.JsonApiResponse.Many
import lu.foyer.JsonApiResponse.One import lu.foyer.JsonApiResponse.One
import lu.foyer.auth.UserInfo
object JsonApiResponse: object JsonApiResponse:
@ -89,7 +90,7 @@ trait JsonApiController:
endpoint: Endpoint[PathInput, Input, JsonApiError, One[Output], AuthType.None] endpoint: Endpoint[PathInput, Input, JsonApiError, One[Output], AuthType.None]
) )
def implementJsonApiOne[Env, A]( def implementJsonApiOne[Env, A](
f: Input => ZIO[Env, JsonApiError | Throwable, Option[A]], f: Input => ZIO[Env & UserInfo, JsonApiError | Throwable, Option[A]],
getId: A => String, getId: A => String,
getEntity: A => Output, getEntity: A => Output,
onthology: String = this.onthology, onthology: String = this.onthology,
@ -99,7 +100,7 @@ trait JsonApiController:
_: ProxyHeaders _: ProxyHeaders
) => None ) => None
)(implicit trace: Trace )(implicit trace: Trace
): Route[Env & ProxyHeaders, Nothing] = ): Route[Env & ProxyHeaders & UserInfo, Nothing] =
endpoint.implement(input => endpoint.implement(input =>
for for
item <- f(input) item <- f(input)
@ -127,7 +128,7 @@ trait JsonApiController:
def implementJsonApiOneEntity[Env]( def implementJsonApiOneEntity[Env](
f: Input => RIO[Env, Option[Entity[Output]]] f: Input => RIO[Env, Option[Entity[Output]]]
)(implicit trace: Trace )(implicit trace: Trace
): Route[Env & ProxyHeaders, Nothing] = ): Route[Env & ProxyHeaders & UserInfo, Nothing] =
implementJsonApiOne( implementJsonApiOne(
f, f,
_.entityId, _.entityId,
@ -140,9 +141,9 @@ trait JsonApiController:
) )
def implementJsonApiOneEvent[Env]( def implementJsonApiOneEvent[Env](
f: Input => ZIO[Env, JsonApiError | Throwable, Option[Event[Output]]] f: Input => ZIO[Env & UserInfo, JsonApiError | Throwable, Option[Event[Output]]]
)(implicit trace: Trace )(implicit trace: Trace
): Route[Env & ProxyHeaders, Nothing] = ): Route[Env & ProxyHeaders & UserInfo, Nothing] =
implementJsonApiOne( implementJsonApiOne(
f, f,
_.eventId, _.eventId,

View file

@ -0,0 +1,25 @@
package lu.foyer
package auth
import zio.*
import zio.http.*
object AuthMiddleware:
def jwtAuthentication(realm: String): HandlerAspect[JwtTokenService, UserInfo] =
HandlerAspect.interceptIncomingHandler {
handler { (request: Request) =>
request.header(Header.Authorization) match
case Some(Header.Authorization.Bearer(token)) =>
ZIO
.serviceWithZIO[JwtTokenService](_.verify(token.value.asString))
.map(UserInfo(_))
.map(userInfo => (request, userInfo))
.orElseFail(
Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm)))
)
case _ =>
ZIO.fail(
Response.unauthorized.addHeaders(Headers(Header.WWWAuthenticate.Bearer(realm)))
)
}
}

View file

@ -0,0 +1,29 @@
package lu.foyer
package auth
import java.time.Clock
import java.util.Base64
import zio.*
import zio.json.*
import lu.foyer.auth.JwtScalaTokenService.Claims
case class JwtScalaTokenService() extends JwtTokenService:
implicit val clock: Clock = Clock.systemUTC
// Doesn't actually verify, we just pretend for simplicity
override def verify(token: String): Task[String] =
token.split('.').drop(1).headOption match
case Some(base64) =>
new String(Base64.getDecoder().decode(base64)).fromJson[Claims] match
case Right(claims) => ZIO.succeed(claims.sub)
case Left(error) =>
ZIO.logWarning(s"Failed to parse JWT claims : $error") *>
ZIO.fail(new Exception("Invalid token"))
case None => ZIO.fail(new Exception("Invalid token"))
object JwtScalaTokenService:
val live = ZLayer.succeed(JwtScalaTokenService())
final private[auth] case class Claims(sub: String) derives JsonDecoder

View file

@ -3,6 +3,8 @@ package lu.foyer
import zio.* import zio.*
import zio.schema.Schema import zio.schema.Schema
import lu.foyer.auth.UserInfo
final case class Entity[T](entityId: String, data: T, version: Long) final case class Entity[T](entityId: String, data: T, version: Long)
final case class Event[T](entityId: String, data: T, eventId: String) final case class Event[T](entityId: String, data: T, eventId: String)
@ -28,13 +30,14 @@ trait CommandHandler[+Command, +Event, +State]:
def commandSchema: Schema[?] def commandSchema: Schema[?]
trait CommandHandlerCreate[Command: Schema, Event] extends CommandHandler[Command, Event, Nothing]: trait CommandHandlerCreate[Command: Schema, Event] extends CommandHandler[Command, Event, Nothing]:
def onCommand(entityId: String, command: Command): IO[JsonApiError | Throwable, Event] def onCommand(entityId: String, command: Command): ZIO[UserInfo, JsonApiError | Throwable, Event]
val isCreate = true val isCreate = true
val commandSchema = summon[Schema[Command]] val commandSchema = summon[Schema[Command]]
trait CommandHandlerUpdate[Command: Schema, Event, State] trait CommandHandlerUpdate[Command: Schema, Event, State]
extends CommandHandler[Command, Event, State]: extends CommandHandler[Command, Event, State]:
def onCommand(entityId: String, state: State, command: Command) : IO[JsonApiError | Throwable, Event] def onCommand(entityId: String, state: State, command: Command)
: ZIO[UserInfo, JsonApiError | Throwable, Event]
val isCreate = false val isCreate = false
val commandSchema = summon[Schema[Command]] val commandSchema = summon[Schema[Command]]
@ -45,7 +48,7 @@ class CommandEngine[Command, Event, State](
val stateRepo: StateRepository[State]): val stateRepo: StateRepository[State]):
def handleCommand(command: Command, name: String, entityId: String) def handleCommand(command: Command, name: String, entityId: String)
: IO[JsonApiError | Throwable, (lu.foyer.Event[Event], lu.foyer.Entity[State])] = : ZIO[UserInfo, JsonApiError | Throwable, (lu.foyer.Event[Event], lu.foyer.Entity[State])] =
for for
handler <- ZIO handler <- ZIO
.succeed(handlers.find(_.name == name)) .succeed(handlers.find(_.name == name))
@ -70,7 +73,7 @@ class CommandEngine[Command, Event, State](
entityId: String, entityId: String,
entityOption: Option[Entity[State]], entityOption: Option[Entity[State]],
handler: CommandHandler[Command, Event, State] handler: CommandHandler[Command, Event, State]
): IO[JsonApiError | Throwable, (Event, Option[State])] = (entityOption, handler) match ): ZIO[UserInfo, JsonApiError | Throwable, (Event, Option[State])] = (entityOption, handler) match
case (None, h) if !h.isUpdate => case (None, h) if !h.isUpdate =>
h.asInstanceOf[CommandHandlerCreate[Command, Event]] h.asInstanceOf[CommandHandlerCreate[Command, Event]]
.onCommand(entityId, command) .onCommand(entityId, command)

View file

@ -0,0 +1,7 @@
package lu.foyer
package auth
import zio.*
trait JwtTokenService:
def verify(token: String): Task[String]

View file

@ -0,0 +1,4 @@
package lu.foyer
package auth
final case class UserInfo(subject: String)

View file

@ -3,6 +3,7 @@ package contracts
import zio.* import zio.*
import lu.foyer.auth.UserInfo
import lu.foyer.clients.* import lu.foyer.clients.*
object ContractHandlers: object ContractHandlers:
@ -92,11 +93,11 @@ class ApproveHandler(employeeService: EmployeeService)
val name = "approve" val name = "approve"
def onCommand(entityId: String, state: ContractState.Pending, command: ContractCommand.Approve) def onCommand(entityId: String, state: ContractState.Pending, command: ContractCommand.Approve)
: Task[ContractEvent.Approved] = : RIO[UserInfo, ContractEvent.Approved] =
for for
user <- ZIO.succeed("") // TODO current user user <- ZIO.service[UserInfo]
employee <- ZIO employee <- ZIO
.fromEither(employeeService.fetchOne(user)) .fromEither(employeeService.fetchOne(user.subject))
.mapError(new IllegalArgumentException(_)) .mapError(new IllegalArgumentException(_))
yield ContractEvent.Approved(employee) yield ContractEvent.Approved(employee)
@ -110,11 +111,11 @@ class RejectHandler(employeeService: EmployeeService)
val name = "reject" val name = "reject"
def onCommand(entityId: String, state: ContractState.Pending, command: ContractCommand.Reject) def onCommand(entityId: String, state: ContractState.Pending, command: ContractCommand.Reject)
: Task[ContractEvent.Rejected] = : RIO[UserInfo, ContractEvent.Rejected] =
for for
user <- ZIO.succeed("") // TODO current user user <- ZIO.service[UserInfo]
employee <- ZIO employee <- ZIO
.fromEither(employeeService.fetchOne(user)) .fromEither(employeeService.fetchOne(user.subject))
.mapError(new IllegalArgumentException(_)) .mapError(new IllegalArgumentException(_))
yield ContractEvent.Rejected(employee, command.comment) yield ContractEvent.Rejected(employee, command.comment)