diff --git a/api/src/lu/foyer/App.scala b/api/src/lu/foyer/App.scala index 0800da6..8463a3c 100644 --- a/api/src/lu/foyer/App.scala +++ b/api/src/lu/foyer/App.scala @@ -16,6 +16,8 @@ import zio.logging.LogFormat.* import zio.logging.consoleLogger import zio.schema.codec.JsonCodec.ExplicitConfig +import lu.foyer.auth.AuthMiddleware.jwtAuthentication +import lu.foyer.auth.JwtScalaTokenService import lu.foyer.clients.* import lu.foyer.contracts.* @@ -29,6 +31,7 @@ object HttpServer: contract <- ZIO.service[ContractController] openAPI = OpenAPIGen.fromEndpoints(client.endpoints ++ contract.endpoints) yield (client.routes ++ contract.routes) + @@ jwtAuthentication("Insurance") @@ cors(corsConfig) @@ Middleware.debug ++ SwaggerUI.routes("docs" / "openapi", openAPI) @@ -52,10 +55,12 @@ object App extends ZIOAppDefault: val app = for routes <- HttpServer.routes - server <- Server.serve(routes).provide(Server.default) + server <- Server.serve(routes) yield server override def run = app.provide( + Server.default, + JwtScalaTokenService.live, CommandEngine.layer[ClientCommand, ClientEvent, ClientState], CommandEngine.layer[ContractCommand, ContractEvent, ContractState], ClientHandlers.layer, diff --git a/api/src/lu/foyer/CommandEngineController.scala b/api/src/lu/foyer/CommandEngineController.scala index 21ed66d..c0f677d 100644 --- a/api/src/lu/foyer/CommandEngineController.scala +++ b/api/src/lu/foyer/CommandEngineController.scala @@ -7,6 +7,8 @@ import zio.http.codec.PathCodec.path import zio.http.endpoint.* import zio.schema.* +import lu.foyer.auth.UserInfo + trait CommandEngineController[Command, Event: Schema, State: Schema] extends JsonApiController: def commandEngine: CommandEngine[Command, Event, State] @@ -36,12 +38,12 @@ trait CommandEngineController[Command, Event: Schema, State: Schema] extends Jso private def generateCreateCommand(handler: CommandHandler[Command, Event, State]) = given Schema[Command] = handler.commandSchema.asInstanceOf[Schema[Command]] - val endpoint = Endpoint(Method.POST / entityName / "commands" / handler.name) + val endpoint = Endpoint(Method.POST / entityName / "commands" / handler.name) .in[Command] .jsonApiOneWithStatus[Event](Status.Created) val route = endpoint.implementJsonApiOneEvent(command => for - entityId <- Random.nextUUID + entityId <- Random.nextUUID (event, _) <- commandEngine .handleCommand(command, handler.name, entityId.toString) yield Some(event) @@ -50,7 +52,7 @@ trait CommandEngineController[Command, Event: Schema, State: Schema] extends Jso private def generateUpdateCommand(handler: CommandHandler[Command, Event, State]) = given Schema[Command] = handler.commandSchema.asInstanceOf[Schema[Command]] - val endpoint = Endpoint( + val endpoint = Endpoint( Method.PUT / entityName / string("entityId") / "commands" / handler.name ) .in[Command] diff --git a/api/src/lu/foyer/JsonApiController.scala b/api/src/lu/foyer/JsonApiController.scala index 8a6b8f2..5b35069 100644 --- a/api/src/lu/foyer/JsonApiController.scala +++ b/api/src/lu/foyer/JsonApiController.scala @@ -8,6 +8,7 @@ import zio.schema.* import lu.foyer.JsonApiResponse.Many import lu.foyer.JsonApiResponse.One +import lu.foyer.auth.UserInfo object JsonApiResponse: @@ -89,7 +90,7 @@ trait JsonApiController: endpoint: Endpoint[PathInput, Input, JsonApiError, One[Output], AuthType.None] ) def implementJsonApiOne[Env, A]( - f: Input => ZIO[Env, JsonApiError | Throwable, Option[A]], + f: Input => ZIO[Env & UserInfo, JsonApiError | Throwable, Option[A]], getId: A => String, getEntity: A => Output, onthology: String = this.onthology, @@ -99,7 +100,7 @@ trait JsonApiController: _: ProxyHeaders ) => None )(implicit trace: Trace - ): Route[Env & ProxyHeaders, Nothing] = + ): Route[Env & ProxyHeaders & UserInfo, Nothing] = endpoint.implement(input => for item <- f(input) @@ -127,7 +128,7 @@ trait JsonApiController: def implementJsonApiOneEntity[Env]( f: Input => RIO[Env, Option[Entity[Output]]] )(implicit trace: Trace - ): Route[Env & ProxyHeaders, Nothing] = + ): Route[Env & ProxyHeaders & UserInfo, Nothing] = implementJsonApiOne( f, _.entityId, @@ -140,9 +141,9 @@ trait JsonApiController: ) 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 - ): Route[Env & ProxyHeaders, Nothing] = + ): Route[Env & ProxyHeaders & UserInfo, Nothing] = implementJsonApiOne( f, _.eventId, diff --git a/api/src/lu/foyer/auth/AuthMiddleware.scala b/api/src/lu/foyer/auth/AuthMiddleware.scala new file mode 100644 index 0000000..095fcdd --- /dev/null +++ b/api/src/lu/foyer/auth/AuthMiddleware.scala @@ -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))) + ) + } + } diff --git a/api/src/lu/foyer/auth/JwtScalaTokenService.scala b/api/src/lu/foyer/auth/JwtScalaTokenService.scala new file mode 100644 index 0000000..86dbeba --- /dev/null +++ b/api/src/lu/foyer/auth/JwtScalaTokenService.scala @@ -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 diff --git a/core/src/lu/foyer/EventSourcing.scala b/core/src/lu/foyer/EventSourcing.scala index 6875137..c4b0bc3 100644 --- a/core/src/lu/foyer/EventSourcing.scala +++ b/core/src/lu/foyer/EventSourcing.scala @@ -3,6 +3,8 @@ package lu.foyer import zio.* import zio.schema.Schema +import lu.foyer.auth.UserInfo + final case class Entity[T](entityId: String, data: T, version: Long) final case class Event[T](entityId: String, data: T, eventId: String) @@ -28,13 +30,14 @@ trait CommandHandler[+Command, +Event, +State]: def commandSchema: Schema[?] 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 commandSchema = summon[Schema[Command]] trait CommandHandlerUpdate[Command: Schema, 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 commandSchema = summon[Schema[Command]] @@ -45,7 +48,7 @@ class CommandEngine[Command, Event, State]( val stateRepo: StateRepository[State]): 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 handler <- ZIO .succeed(handlers.find(_.name == name)) @@ -70,7 +73,7 @@ class CommandEngine[Command, Event, State]( entityId: String, entityOption: Option[Entity[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 => h.asInstanceOf[CommandHandlerCreate[Command, Event]] .onCommand(entityId, command) diff --git a/core/src/lu/foyer/auth/JwtTokenService.scala b/core/src/lu/foyer/auth/JwtTokenService.scala new file mode 100644 index 0000000..49f19f0 --- /dev/null +++ b/core/src/lu/foyer/auth/JwtTokenService.scala @@ -0,0 +1,7 @@ +package lu.foyer +package auth + +import zio.* + +trait JwtTokenService: + def verify(token: String): Task[String] diff --git a/core/src/lu/foyer/auth/UserInfo.scala b/core/src/lu/foyer/auth/UserInfo.scala new file mode 100644 index 0000000..cf15df9 --- /dev/null +++ b/core/src/lu/foyer/auth/UserInfo.scala @@ -0,0 +1,4 @@ +package lu.foyer +package auth + +final case class UserInfo(subject: String) diff --git a/core/src/lu/foyer/contracts/ContractHandlers.scala b/core/src/lu/foyer/contracts/ContractHandlers.scala index fb139f8..f703ec9 100644 --- a/core/src/lu/foyer/contracts/ContractHandlers.scala +++ b/core/src/lu/foyer/contracts/ContractHandlers.scala @@ -3,6 +3,7 @@ package contracts import zio.* +import lu.foyer.auth.UserInfo import lu.foyer.clients.* object ContractHandlers: @@ -92,11 +93,11 @@ class ApproveHandler(employeeService: EmployeeService) val name = "approve" def onCommand(entityId: String, state: ContractState.Pending, command: ContractCommand.Approve) - : Task[ContractEvent.Approved] = + : RIO[UserInfo, ContractEvent.Approved] = for - user <- ZIO.succeed("") // TODO current user + user <- ZIO.service[UserInfo] employee <- ZIO - .fromEither(employeeService.fetchOne(user)) + .fromEither(employeeService.fetchOne(user.subject)) .mapError(new IllegalArgumentException(_)) yield ContractEvent.Approved(employee) @@ -110,11 +111,11 @@ class RejectHandler(employeeService: EmployeeService) val name = "reject" def onCommand(entityId: String, state: ContractState.Pending, command: ContractCommand.Reject) - : Task[ContractEvent.Rejected] = + : RIO[UserInfo, ContractEvent.Rejected] = for - user <- ZIO.succeed("") // TODO current user + user <- ZIO.service[UserInfo] employee <- ZIO - .fromEither(employeeService.fetchOne(user)) + .fromEither(employeeService.fetchOne(user.subject)) .mapError(new IllegalArgumentException(_)) yield ContractEvent.Rejected(employee, command.comment)