diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala new file mode 100644 index 0000000..0179ab2 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala @@ -0,0 +1,8 @@ +package works.iterative.tapir + +import sttp.tapir.Endpoint +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams + +type ApiEndpoint[E, I, O] = + Endpoint[AccessToken, I, ApiError[E], O, ZioStreams] diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala new file mode 100644 index 0000000..0179ab2 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala @@ -0,0 +1,8 @@ +package works.iterative.tapir + +import sttp.tapir.Endpoint +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams + +type ApiEndpoint[E, I, O] = + Endpoint[AccessToken, I, ApiError[E], O, ZioStreams] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala new file mode 100644 index 0000000..f0161c4 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala @@ -0,0 +1,9 @@ +package works.iterative.tapir + +import works.iterative.core.auth.service.AuthenticationError + +sealed trait ApiError[+ClientError] +object ApiError: + case class AuthFailure(error: AuthenticationError) extends ApiError[Nothing] + case class RequestFailure[ClientError](error: ClientError) + extends ApiError[ClientError] diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala new file mode 100644 index 0000000..0179ab2 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala @@ -0,0 +1,8 @@ +package works.iterative.tapir + +import sttp.tapir.Endpoint +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams + +type ApiEndpoint[E, I, O] = + Endpoint[AccessToken, I, ApiError[E], O, ZioStreams] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala new file mode 100644 index 0000000..f0161c4 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala @@ -0,0 +1,9 @@ +package works.iterative.tapir + +import works.iterative.core.auth.service.AuthenticationError + +sealed trait ApiError[+ClientError] +object ApiError: + case class AuthFailure(error: AuthenticationError) extends ApiError[Nothing] + case class RequestFailure[ClientError](error: ClientError) + extends ApiError[ClientError] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala index dd52e9b..806d9d3 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala @@ -3,12 +3,53 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams +import works.iterative.core.auth.service.AuthenticationError +import sttp.model.StatusCode +import works.iterative.tapir.codecs.Codecs.given +import zio.* +import zio.json.* trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with CustomTapirPlatformSpecific: - given Schema[ServerError] = Schema.derived + with CustomTapirPlatformSpecific -object CustomTapir extends CustomTapir +object CustomTapir extends CustomTapir: + type ApiError[+E] = works.iterative.tapir.ApiError[E] + val ApiError = works.iterative.tapir.ApiError + + type ApiEndpoint[E, I, O] = works.iterative.tapir.ApiEndpoint[E, I, O] + + given apiRequestFailureCodec[E: JsonCodec] + : JsonCodec[ApiError.RequestFailure[E]] = + DeriveJsonCodec.gen + given apiRequestFailureSchema[E: Schema]: Schema[ApiError.RequestFailure[E]] = + Schema.derived + + given authenticationErrorCodec: JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen + given authFailureCodec: JsonCodec[ApiError.AuthFailure] = DeriveJsonCodec.gen + given authenticationErrorSchema: Schema[AuthenticationError] = Schema.derived + given authFailureSchema: Schema[ApiError.AuthFailure] = Schema.derived + + given JsonCodec[Unit] = JsonCodec.string.transform(_ => (), _ => "") + + extension [I, O](base: Endpoint[Unit, I, Unit, O, ZioStreams]) + def toApi[E: JsonCodec: Schema]: ApiEndpoint[E, I, O] = + base + .securityIn(auth.bearer[AccessToken]()) + .errorOut( + oneOf[ApiError[E]]( + oneOfVariant[ApiError.AuthFailure]( + StatusCode.Unauthorized, + jsonBody[ApiError.AuthFailure] + ), + oneOfDefaultVariant[ApiError.RequestFailure[E]]( + statusCode(StatusCode.BadRequest) + .and(jsonBody[ApiError.RequestFailure[E]]) + ) + ) + ) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala new file mode 100644 index 0000000..0179ab2 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala @@ -0,0 +1,8 @@ +package works.iterative.tapir + +import sttp.tapir.Endpoint +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams + +type ApiEndpoint[E, I, O] = + Endpoint[AccessToken, I, ApiError[E], O, ZioStreams] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala new file mode 100644 index 0000000..f0161c4 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala @@ -0,0 +1,9 @@ +package works.iterative.tapir + +import works.iterative.core.auth.service.AuthenticationError + +sealed trait ApiError[+ClientError] +object ApiError: + case class AuthFailure(error: AuthenticationError) extends ApiError[Nothing] + case class RequestFailure[ClientError](error: ClientError) + extends ApiError[ClientError] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala index dd52e9b..806d9d3 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala @@ -3,12 +3,53 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams +import works.iterative.core.auth.service.AuthenticationError +import sttp.model.StatusCode +import works.iterative.tapir.codecs.Codecs.given +import zio.* +import zio.json.* trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with CustomTapirPlatformSpecific: - given Schema[ServerError] = Schema.derived + with CustomTapirPlatformSpecific -object CustomTapir extends CustomTapir +object CustomTapir extends CustomTapir: + type ApiError[+E] = works.iterative.tapir.ApiError[E] + val ApiError = works.iterative.tapir.ApiError + + type ApiEndpoint[E, I, O] = works.iterative.tapir.ApiEndpoint[E, I, O] + + given apiRequestFailureCodec[E: JsonCodec] + : JsonCodec[ApiError.RequestFailure[E]] = + DeriveJsonCodec.gen + given apiRequestFailureSchema[E: Schema]: Schema[ApiError.RequestFailure[E]] = + Schema.derived + + given authenticationErrorCodec: JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen + given authFailureCodec: JsonCodec[ApiError.AuthFailure] = DeriveJsonCodec.gen + given authenticationErrorSchema: Schema[AuthenticationError] = Schema.derived + given authFailureSchema: Schema[ApiError.AuthFailure] = Schema.derived + + given JsonCodec[Unit] = JsonCodec.string.transform(_ => (), _ => "") + + extension [I, O](base: Endpoint[Unit, I, Unit, O, ZioStreams]) + def toApi[E: JsonCodec: Schema]: ApiEndpoint[E, I, O] = + base + .securityIn(auth.bearer[AccessToken]()) + .errorOut( + oneOf[ApiError[E]]( + oneOfVariant[ApiError.AuthFailure]( + StatusCode.Unauthorized, + jsonBody[ApiError.AuthFailure] + ), + oneOfDefaultVariant[ApiError.RequestFailure[E]]( + statusCode(StatusCode.BadRequest) + .and(jsonBody[ApiError.RequestFailure[E]]) + ) + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala index 992feac..ce9f651 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala @@ -28,7 +28,10 @@ ): m.Result = m.makeResult((securityInput: S) => (input: I) => val req = makeRequest(endpoint) - val fetch = req(securityInput)(input).followRedirects(false).send(backend) + val fetch = req(securityInput)(input) + .header("is_ajax_request", "true") + .followRedirects(false) + .send(backend) val result = for resp <- fetch.orDie diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala new file mode 100644 index 0000000..0179ab2 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala @@ -0,0 +1,8 @@ +package works.iterative.tapir + +import sttp.tapir.Endpoint +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams + +type ApiEndpoint[E, I, O] = + Endpoint[AccessToken, I, ApiError[E], O, ZioStreams] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala new file mode 100644 index 0000000..f0161c4 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala @@ -0,0 +1,9 @@ +package works.iterative.tapir + +import works.iterative.core.auth.service.AuthenticationError + +sealed trait ApiError[+ClientError] +object ApiError: + case class AuthFailure(error: AuthenticationError) extends ApiError[Nothing] + case class RequestFailure[ClientError](error: ClientError) + extends ApiError[ClientError] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala index dd52e9b..806d9d3 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala @@ -3,12 +3,53 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams +import works.iterative.core.auth.service.AuthenticationError +import sttp.model.StatusCode +import works.iterative.tapir.codecs.Codecs.given +import zio.* +import zio.json.* trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with CustomTapirPlatformSpecific: - given Schema[ServerError] = Schema.derived + with CustomTapirPlatformSpecific -object CustomTapir extends CustomTapir +object CustomTapir extends CustomTapir: + type ApiError[+E] = works.iterative.tapir.ApiError[E] + val ApiError = works.iterative.tapir.ApiError + + type ApiEndpoint[E, I, O] = works.iterative.tapir.ApiEndpoint[E, I, O] + + given apiRequestFailureCodec[E: JsonCodec] + : JsonCodec[ApiError.RequestFailure[E]] = + DeriveJsonCodec.gen + given apiRequestFailureSchema[E: Schema]: Schema[ApiError.RequestFailure[E]] = + Schema.derived + + given authenticationErrorCodec: JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen + given authFailureCodec: JsonCodec[ApiError.AuthFailure] = DeriveJsonCodec.gen + given authenticationErrorSchema: Schema[AuthenticationError] = Schema.derived + given authFailureSchema: Schema[ApiError.AuthFailure] = Schema.derived + + given JsonCodec[Unit] = JsonCodec.string.transform(_ => (), _ => "") + + extension [I, O](base: Endpoint[Unit, I, Unit, O, ZioStreams]) + def toApi[E: JsonCodec: Schema]: ApiEndpoint[E, I, O] = + base + .securityIn(auth.bearer[AccessToken]()) + .errorOut( + oneOf[ApiError[E]]( + oneOfVariant[ApiError.AuthFailure]( + StatusCode.Unauthorized, + jsonBody[ApiError.AuthFailure] + ), + oneOfDefaultVariant[ApiError.RequestFailure[E]]( + statusCode(StatusCode.BadRequest) + .and(jsonBody[ApiError.RequestFailure[E]]) + ) + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala index 992feac..ce9f651 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala @@ -28,7 +28,10 @@ ): m.Result = m.makeResult((securityInput: S) => (input: I) => val req = makeRequest(endpoint) - val fetch = req(securityInput)(input).followRedirects(false).send(backend) + val fetch = req(securityInput)(input) + .header("is_ajax_request", "true") + .followRedirects(false) + .send(backend) val result = for resp <- fetch.orDie diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala deleted file mode 100644 index 715591d..0000000 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.tapir - -import zio.json.* - -sealed trait ServerError -case class InternalServerError(msg: String) extends ServerError -object InternalServerError: - def fromThrowable(t: Throwable): ServerError = InternalServerError( - t.getMessage - ) - -object ServerError: - given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala new file mode 100644 index 0000000..0179ab2 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala @@ -0,0 +1,8 @@ +package works.iterative.tapir + +import sttp.tapir.Endpoint +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams + +type ApiEndpoint[E, I, O] = + Endpoint[AccessToken, I, ApiError[E], O, ZioStreams] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala new file mode 100644 index 0000000..f0161c4 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala @@ -0,0 +1,9 @@ +package works.iterative.tapir + +import works.iterative.core.auth.service.AuthenticationError + +sealed trait ApiError[+ClientError] +object ApiError: + case class AuthFailure(error: AuthenticationError) extends ApiError[Nothing] + case class RequestFailure[ClientError](error: ClientError) + extends ApiError[ClientError] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala index dd52e9b..806d9d3 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala @@ -3,12 +3,53 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams +import works.iterative.core.auth.service.AuthenticationError +import sttp.model.StatusCode +import works.iterative.tapir.codecs.Codecs.given +import zio.* +import zio.json.* trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with CustomTapirPlatformSpecific: - given Schema[ServerError] = Schema.derived + with CustomTapirPlatformSpecific -object CustomTapir extends CustomTapir +object CustomTapir extends CustomTapir: + type ApiError[+E] = works.iterative.tapir.ApiError[E] + val ApiError = works.iterative.tapir.ApiError + + type ApiEndpoint[E, I, O] = works.iterative.tapir.ApiEndpoint[E, I, O] + + given apiRequestFailureCodec[E: JsonCodec] + : JsonCodec[ApiError.RequestFailure[E]] = + DeriveJsonCodec.gen + given apiRequestFailureSchema[E: Schema]: Schema[ApiError.RequestFailure[E]] = + Schema.derived + + given authenticationErrorCodec: JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen + given authFailureCodec: JsonCodec[ApiError.AuthFailure] = DeriveJsonCodec.gen + given authenticationErrorSchema: Schema[AuthenticationError] = Schema.derived + given authFailureSchema: Schema[ApiError.AuthFailure] = Schema.derived + + given JsonCodec[Unit] = JsonCodec.string.transform(_ => (), _ => "") + + extension [I, O](base: Endpoint[Unit, I, Unit, O, ZioStreams]) + def toApi[E: JsonCodec: Schema]: ApiEndpoint[E, I, O] = + base + .securityIn(auth.bearer[AccessToken]()) + .errorOut( + oneOf[ApiError[E]]( + oneOfVariant[ApiError.AuthFailure]( + StatusCode.Unauthorized, + jsonBody[ApiError.AuthFailure] + ), + oneOfDefaultVariant[ApiError.RequestFailure[E]]( + statusCode(StatusCode.BadRequest) + .and(jsonBody[ApiError.RequestFailure[E]]) + ) + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala index 992feac..ce9f651 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala @@ -28,7 +28,10 @@ ): m.Result = m.makeResult((securityInput: S) => (input: I) => val req = makeRequest(endpoint) - val fetch = req(securityInput)(input).followRedirects(false).send(backend) + val fetch = req(securityInput)(input) + .header("is_ajax_request", "true") + .followRedirects(false) + .send(backend) val result = for resp <- fetch.orDie diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala deleted file mode 100644 index 715591d..0000000 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.tapir - -import zio.json.* - -sealed trait ServerError -case class InternalServerError(msg: String) extends ServerError -object InternalServerError: - def fromThrowable(t: Throwable): ServerError = InternalServerError( - t.getMessage - ) - -object ServerError: - given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala new file mode 100644 index 0000000..e120d49 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala @@ -0,0 +1,101 @@ +package works.iterative +package tapir.codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir.* +import works.iterative.core.* +import works.iterative.core.auth.* +import works.iterative.core.auth.service.AuthenticationError +import sttp.tapir.CodecFormat + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = + v.mapError(_.id).toEither.left.map(_.mkString(",")) + + def textCodec[T]( + f: String => Validation[UserMessage, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + + given JsonCodec[PermissionOp] = + JsonCodec.string.transform(PermissionOp(_), _.value) + given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) + + given JsonCodec[UserId] = + JsonCodec.string.transform(UserId.unsafe(_), _.value) + + given JsonCodec[Email] = validatedStringCodec(Email) + + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) + given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + + given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] + + given JsonCodec[Moment] = JsonCodec.instant.transform( + Moment(_), + _.toInstant + ) + given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] + +trait TapirCodecs: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + + given Schema[PermissionOp] = Schema.string + given Schema[PermissionTarget] = Schema.string + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[Claim] = Schema.derived[Claim] + given Schema[BasicProfile] = Schema.derived[BasicProfile] + given Schema[FileRef] = Schema.derived[FileRef] + given Schema[Moment] = + Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) + given Schema[UserHandle] = Schema.derived[UserHandle] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) + +object Codecs extends Codecs diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala new file mode 100644 index 0000000..0179ab2 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala @@ -0,0 +1,8 @@ +package works.iterative.tapir + +import sttp.tapir.Endpoint +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams + +type ApiEndpoint[E, I, O] = + Endpoint[AccessToken, I, ApiError[E], O, ZioStreams] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala new file mode 100644 index 0000000..f0161c4 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala @@ -0,0 +1,9 @@ +package works.iterative.tapir + +import works.iterative.core.auth.service.AuthenticationError + +sealed trait ApiError[+ClientError] +object ApiError: + case class AuthFailure(error: AuthenticationError) extends ApiError[Nothing] + case class RequestFailure[ClientError](error: ClientError) + extends ApiError[ClientError] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala index dd52e9b..806d9d3 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala @@ -3,12 +3,53 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams +import works.iterative.core.auth.service.AuthenticationError +import sttp.model.StatusCode +import works.iterative.tapir.codecs.Codecs.given +import zio.* +import zio.json.* trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with CustomTapirPlatformSpecific: - given Schema[ServerError] = Schema.derived + with CustomTapirPlatformSpecific -object CustomTapir extends CustomTapir +object CustomTapir extends CustomTapir: + type ApiError[+E] = works.iterative.tapir.ApiError[E] + val ApiError = works.iterative.tapir.ApiError + + type ApiEndpoint[E, I, O] = works.iterative.tapir.ApiEndpoint[E, I, O] + + given apiRequestFailureCodec[E: JsonCodec] + : JsonCodec[ApiError.RequestFailure[E]] = + DeriveJsonCodec.gen + given apiRequestFailureSchema[E: Schema]: Schema[ApiError.RequestFailure[E]] = + Schema.derived + + given authenticationErrorCodec: JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen + given authFailureCodec: JsonCodec[ApiError.AuthFailure] = DeriveJsonCodec.gen + given authenticationErrorSchema: Schema[AuthenticationError] = Schema.derived + given authFailureSchema: Schema[ApiError.AuthFailure] = Schema.derived + + given JsonCodec[Unit] = JsonCodec.string.transform(_ => (), _ => "") + + extension [I, O](base: Endpoint[Unit, I, Unit, O, ZioStreams]) + def toApi[E: JsonCodec: Schema]: ApiEndpoint[E, I, O] = + base + .securityIn(auth.bearer[AccessToken]()) + .errorOut( + oneOf[ApiError[E]]( + oneOfVariant[ApiError.AuthFailure]( + StatusCode.Unauthorized, + jsonBody[ApiError.AuthFailure] + ), + oneOfDefaultVariant[ApiError.RequestFailure[E]]( + statusCode(StatusCode.BadRequest) + .and(jsonBody[ApiError.RequestFailure[E]]) + ) + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala index 992feac..ce9f651 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala @@ -28,7 +28,10 @@ ): m.Result = m.makeResult((securityInput: S) => (input: I) => val req = makeRequest(endpoint) - val fetch = req(securityInput)(input).followRedirects(false).send(backend) + val fetch = req(securityInput)(input) + .header("is_ajax_request", "true") + .followRedirects(false) + .send(backend) val result = for resp <- fetch.orDie diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala deleted file mode 100644 index 715591d..0000000 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.tapir - -import zio.json.* - -sealed trait ServerError -case class InternalServerError(msg: String) extends ServerError -object InternalServerError: - def fromThrowable(t: Throwable): ServerError = InternalServerError( - t.getMessage - ) - -object ServerError: - given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala new file mode 100644 index 0000000..e120d49 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala @@ -0,0 +1,101 @@ +package works.iterative +package tapir.codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir.* +import works.iterative.core.* +import works.iterative.core.auth.* +import works.iterative.core.auth.service.AuthenticationError +import sttp.tapir.CodecFormat + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = + v.mapError(_.id).toEither.left.map(_.mkString(",")) + + def textCodec[T]( + f: String => Validation[UserMessage, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + + given JsonCodec[PermissionOp] = + JsonCodec.string.transform(PermissionOp(_), _.value) + given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) + + given JsonCodec[UserId] = + JsonCodec.string.transform(UserId.unsafe(_), _.value) + + given JsonCodec[Email] = validatedStringCodec(Email) + + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) + given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + + given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] + + given JsonCodec[Moment] = JsonCodec.instant.transform( + Moment(_), + _.toInstant + ) + given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] + +trait TapirCodecs: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + + given Schema[PermissionOp] = Schema.string + given Schema[PermissionTarget] = Schema.string + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[Claim] = Schema.derived[Claim] + given Schema[BasicProfile] = Schema.derived[BasicProfile] + given Schema[FileRef] = Schema.derived[FileRef] + given Schema[Moment] = + Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) + given Schema[UserHandle] = Schema.derived[UserHandle] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) + +object Codecs extends Codecs diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/AuthenticationEndpoints.scala b/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/AuthenticationEndpoints.scala new file mode 100644 index 0000000..c060d9b --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/AuthenticationEndpoints.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir +package endpoints + +import works.iterative.tapir.codecs.Codecs.given +import works.iterative.core.auth.AuthedUserInfo +import CustomTapir.* + +trait AuthenticationEndpoints(base: BaseEndpoint): + + val currentUser: Endpoint[Unit, Unit, Unit, Option[AuthedUserInfo], Any] = + base.get + .in("user" / "me") + .out(jsonBody[Option[AuthedUserInfo]]) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 932d04d..7dbcba5 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -3,102 +3,15 @@ package codecs import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir -import works.iterative.core.auth.* +import works.iterative.tapir.CustomTapir.* import works.iterative.event.EventRecord -import sttp.tapir.CodecFormat -import works.iterative.core.auth.service.AuthenticationError - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) trait Codecs extends JsonCodecs with TapirCodecs -trait JsonCodecs: - - def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = - v.mapError(_.id).toEither.left.map(_.mkString(",")) - - def textCodec[T]( - f: String => Validation[UserMessage, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - def validatedStringCodec[A]( - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) - - given fromValidatedStringCodec[A](using - factory: ValidatedStringFactory[A] - ): JsonCodec[A] = validatedStringCodec(factory) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - - given JsonCodec[PermissionOp] = - JsonCodec.string.transform(PermissionOp(_), _.value) - given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) - - given JsonCodec[UserId] = - JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - - given JsonCodec[Email] = validatedStringCodec(Email) - - given JsonCodec[UserRole] = validatedStringCodec(UserRole) - given JsonCodec[Avatar] = validatedStringCodec(Avatar) - given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] - given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] - - given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] - - given JsonCodec[Moment] = JsonCodec.instant.transform( - Moment(_), - _.toInstant - ) - given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] +trait JsonCodecs extends works.iterative.tapir.codecs.JsonCodecs: given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] - given JsonCodec[AccessToken] = - JsonCodec.string.transform(AccessToken(_), _.token) - given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] - given JsonCodec[AuthenticationError] = - DeriveJsonCodec.gen[AuthenticationError] -trait TapirCodecs extends CustomTapir: - given fromValidatedStringSchema[A](using - ValidatedStringFactory[A] - ): Schema[A] = Schema.string - - given Schema[PermissionOp] = Schema.string - given Schema[PermissionTarget] = Schema.string - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string - given Schema[UserId] = Schema.string - given Schema[UserRole] = Schema.string - given Schema[UserName] = Schema.string - given Schema[Avatar] = Schema.string - given Schema[Email] = Schema.string - given Schema[Claim] = Schema.derived[Claim] - given Schema[BasicProfile] = Schema.derived[BasicProfile] - given Schema[FileRef] = Schema.derived[FileRef] - given Schema[Moment] = - Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) - given Schema[UserHandle] = Schema.derived[UserHandle] +trait TapirCodecs extends works.iterative.tapir.codecs.TapirCodecs: given Schema[EventRecord] = Schema.derived[EventRecord] - given Schema[AccessToken] = Schema.string - given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] - given Schema[AuthenticationError] = Schema.derived[AuthenticationError] - - given Codec[String, AccessToken, CodecFormat.TextPlain] = - Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala deleted file mode 100644 index 1dff6e1..0000000 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.core.auth -package service - -import zio.* - -object Authentication extends AuthenticationService: - private val currentUser: FiberRef[Option[AuthedUserInfo]] = - Unsafe.unsafely( - FiberRef.unsafe.make(None) - ) - - override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get - - override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = - currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala index d714f98..c64a72e 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -14,9 +14,16 @@ extends AuthenticationError(UserMessage("error.not.logged.in")) trait AuthenticationService: + def loggedIn(user: AuthedUserInfo): UIO[Unit] = + loggedIn(user.token, user.profile) + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + def currentUser: UIO[Option[BasicProfile]] = + currentUserInfo.map(_.map(_.profile)) + def currentAccessToken: UIO[Option[AccessToken]] = currentUserInfo.map(_.map(_.token)) @@ -28,3 +35,55 @@ effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) case None => ZIO.fail(AuthenticationError.NotLoggedIn) } + +object FiberRefAuthentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object GlobalRefAuthentication extends AuthenticationService: + private val currentUser: Ref[Option[AuthedUserInfo]] = + Unsafe.unsafely( + Ref.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) + +object AuthenticationService: + val layer: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(FiberRefAuthentication) + + val global: ZLayer[Any, Nothing, AuthenticationService] = + ZLayer.succeed(GlobalRefAuthentication) + + def currentAccessToken: URIO[AuthenticationService, Option[AccessToken]] = + ZIO.serviceWithZIO(_.currentAccessToken) + + def currentUserInfo: URIO[AuthenticationService, Option[AuthedUserInfo]] = + ZIO.serviceWithZIO(_.currentUserInfo) + + def currentUser: URIO[AuthenticationService, Option[BasicProfile]] = + ZIO.serviceWithZIO(_.currentUser) + + def loggedIn( + token: AccessToken, + profile: BasicProfile + ): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(token, profile)) + + def loggedIn(user: AuthedUserInfo): URIO[AuthenticationService, Unit] = + ZIO.serviceWithZIO(_.loggedIn(user)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R & AuthenticationService, E | AuthenticationError, A] = + ZIO.serviceWithZIO[AuthenticationService](_.provideCurrentUser(effect)) diff --git a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala index 6ca5ef6..7f0a15d 100644 --- a/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/HttpServer.scala @@ -1,10 +1,15 @@ package works.iterative.server.http import zio.* +import works.iterative.core.auth.service.AuthenticationService trait HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] object HttpServer: - def serve[Env](app: HttpApplication[Env]): URIO[Env & HttpServer, Nothing] = + def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env & HttpServer, Nothing] = ZIO.serviceWithZIO[HttpServer](_.serve(app)) diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala index 309f90e..5405f04 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/blaze/BlazeHttpServer.scala @@ -17,16 +17,20 @@ import works.iterative.tapir.BaseUri import org.http4s.server.Router import org.http4s.server.websocket.WebSocketBuilder2 -import org.pac4j.core.profile.CommonProfile import works.iterative.core.auth.BasicProfile +import org.pac4j.oidc.profile.OidcProfile +import works.iterative.core.auth.AuthedUserInfo +import works.iterative.core.auth.service.AuthenticationService class BlazeHttpServer( config: BlazeServerConfig, pac4jConfig: Pac4jSecurityConfig, baseUri: BaseUri, - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpServer: - override def serve[Env](app: HttpApplication[Env]): URIO[Env, Nothing] = + override def serve[Env <: AuthenticationService]( + app: HttpApplication[Env] + ): URIO[Env, Nothing] = type AppTask[A] = RIO[Env, A] type SecuredTask[A] = RIO[Env & CurrentUser, A] @@ -37,7 +41,7 @@ req, conf.getSessionStore, t => - Unsafe.unsafe(implicit unsafe => + Unsafe.unsafely( runtime.unsafe.run(t).getOrThrowFiberFailure() ) ) @@ -45,29 +49,32 @@ val pac4jSecurity = Pac4jHttpSecurity[AppTask](pac4jConfig, contextBuilder, updateProfile) + // TODO: remove the SecuredTask and provide just the authentication when the move to AuthenticationService is done. def provideCurrentUser( routes: HttpRoutes[SecuredTask] ): HttpRoutes[AppTask] = - def secureRoutes: AuthedRoutes[CurrentUser, AppTask] = Kleisli { ctx => - val currentUser = ctx.context - val userEnv = ZEnvironment(currentUser) + def secureRoutes: AuthedRoutes[AuthedUserInfo, AppTask] = + Kleisli { ctx => + val authedUserInfo = ctx.context + val userEnv = ZEnvironment(CurrentUser(authedUserInfo.profile)) - // Just add CurrentUser to the env, the effect does not need it anyway - val widenCurrentUser: AppTask ~> SecuredTask = - new FunctionK[AppTask, SecuredTask]: - override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa + // Just add CurrentUser to the env, the effect does not need it anyway + val widenCurrentUser: AppTask ~> SecuredTask = + new FunctionK[AppTask, SecuredTask]: + override def apply[A](fa: AppTask[A]): SecuredTask[A] = fa - // Provide - val eliminateCurrentUser: SecuredTask ~> AppTask = - new FunctionK[SecuredTask, AppTask]: - override def apply[A](fa: SecuredTask[A]): AppTask[A] = - fa.provideSomeEnvironment[Env](env => env ++ userEnv) + // Provide + val eliminateCurrentUser: SecuredTask ~> AppTask = + new FunctionK[SecuredTask, AppTask]: + override def apply[A](fa: SecuredTask[A]): AppTask[A] = + AuthenticationService.loggedIn(authedUserInfo) *> fa + .provideSomeEnvironment[Env](env => env ++ userEnv) - routes - .run(ctx.req.mapK(widenCurrentUser)) - .map(_.mapK(eliminateCurrentUser)) - .mapK(eliminateCurrentUser) - } + routes + .run(ctx.req.mapK(widenCurrentUser)) + .map(_.mapK(eliminateCurrentUser)) + .mapK(eliminateCurrentUser) + } pac4jSecurity.secure(secureRoutes) @@ -104,7 +111,7 @@ object BlazeHttpServer: def layer( - updateProfile: (CommonProfile, BasicProfile) => BasicProfile = (_, u) => u + updateProfile: (OidcProfile, BasicProfile) => BasicProfile = (_, u) => u ): RLayer[BlazeServerConfig & Pac4jSecurityConfig & BaseUri, HttpServer] = ZLayer { for diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index c091805..3652278 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -10,7 +10,6 @@ import cats.effect.Sync import scala.concurrent.duration.given -import works.iterative.core.auth.CurrentUser import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router @@ -19,13 +18,14 @@ import works.iterative.core.Email import works.iterative.core.Avatar import works.iterative.core.auth.* +import org.pac4j.oidc.profile.OidcProfile trait HttpSecurity class Pac4jHttpSecurity[F[_] <: AnyRef: Sync]( config: Pac4jSecurityConfig, contextBuilder: (Request[F], Config) => Http4sWebContext[F], - updateProfile: (CommonProfile, BasicProfile) => BasicProfile + updateProfile: (OidcProfile, BasicProfile) => BasicProfile ) extends HttpSecurity: protected val dsl: Http4sDsl[F] = new Http4sDsl[F] {} import dsl.* @@ -69,14 +69,15 @@ // TODO: factor this middleware out to make this Pac4J service general val currentUserSecurityFilter - : Middleware[OptionT[F, *], AuthedRequest[F, CurrentUser], Response[ + : Middleware[OptionT[F, *], AuthedRequest[F, AuthedUserInfo], Response[ F ], AuthedRequest[F, List[CommonProfile]], Response[F]] = service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => - def loggedInUser(p: CommonProfile): CurrentUser = + def loggedInUser(p: OidcProfile): AuthedUserInfo = import scala.jdk.CollectionConverters.* - CurrentUser( + AuthedUserInfo( + AccessToken(p.getAccessToken().toString()), updateProfile( p, BasicProfile( @@ -92,7 +93,7 @@ ) ) r.context match { - case profile :: _ => + case (profile: OidcProfile) :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) // TODO: Report error properly case _ => OptionT.none @@ -114,7 +115,7 @@ def route: HttpRoutes[F] = Router(s"${config.callbackBase}" -> sessionManagement(routes)) - def secure: AuthMiddleware[F, CurrentUser] = + def secure: AuthMiddleware[F, AuthedUserInfo] = sessionManagement .compose(baseSecurityFilter) .compose(currentUserSecurityFilter) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala new file mode 100644 index 0000000..2b9672c --- /dev/null +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/AuthApi.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import endpoints.AuthenticationEndpoints +import zio.* +import CustomTapir.* +import works.iterative.core.auth.service.AuthenticationService + +trait AuthApi(ep: AuthenticationEndpoints) { + val currentUser: ZServerEndpoint[AuthenticationService, Any] = + ep.currentUser.zServerLogic { _ => + ZIO.serviceWithZIO[AuthenticationService](_.currentUserInfo) + } +} diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index f2a3e6d..65ebfe3 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -13,12 +13,17 @@ import java.net.URI import sttp.capabilities.zio.ZioStreams import sttp.capabilities.WebSockets +import works.iterative.core.auth.service.AuthenticationService +import works.iterative.core.auth.CurrentUser +import works.iterative.core.auth.service.AuthenticationError trait CustomTapirPlatformSpecific extends ZTapir with SttpClientInterpreter: self: CustomTapir => type Backend = SttpBackend[Task, ZioStreams & WebSockets] + type ZApiEndpoint[R] = ZServerEndpoint[R & AuthenticationService, ZioStreams] + private def addSession( session: String ): HttpClient.Builder => HttpClient.Builder = @@ -72,3 +77,21 @@ .followRedirects(false) .send(backend) .map(_.body) + + extension [E, I, O](endpoint: ApiEndpoint[E, I, O]) + def apiLogic[R <: AuthenticationService]( + logic: I => ZIO[R & CurrentUser, E | AuthenticationError, O] + ): ZServerEndpoint[R, ZioStreams] = + endpoint + .zServerSecurityLogic(_ => ZIO.unit) + .serverLogic(_ => + (i: I) => + ZIO.serviceWithZIO[AuthenticationService]( + _.provideCurrentUser(logic(i)) + .mapError { + case a: AuthenticationError => ApiError.AuthFailure(a) + // Well, we have E | AuthenticationError and we match AuthenticationError above, so what is left? + case e => ApiError.RequestFailure(e.asInstanceOf[E]) + } + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala index e1a7690..de4e1d2 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -7,14 +7,27 @@ import works.iterative.core.auth.AccessToken import works.iterative.core.auth.service.* +/** Create effectful methods to perform the endpoint operation. + * + * The factory takes an endpoint with correct type signature, and returns a + * function that can call the endpoint. + * + * The resulting error channel is whatever the endpoint declares as the client + * error channel, eg. the type E of ApiError[E], which is what is reported in + * RequestFailure[E]. + * + * The other options - AuthenticationFailure, ServerFailure - are converted to + * defects to be handled at another level. + * + * This way the client can deal only with what it can actually do something + * about. + */ trait ApiClientFactory: - // TODO: Handle all authentication errors here, make sure that we remove them from the type system - // Authentication errors do not seem to be defects. def make[I, E, O]( endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -31,7 +44,7 @@ endpoint: Endpoint[ AccessToken, I, - E, + ApiError[E], O, ZioStreams & WebSockets ] @@ -39,23 +52,27 @@ b: BaseUriExtractor[O], e: ClientErrorConstructor[E] ): I => IO[e.Error, O] = - val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + val client: AccessToken => I => IO[ApiError[E], O] = clientFactory.make( endpoint - )(using b, e, ClientResultConstructor.secureResultConstructor) + )(using + b, + ClientErrorConstructor.errorConstructor[ApiError[E]], + ClientResultConstructor.secureResultConstructor + ) input => authentication.currentAccessToken.flatMap { - case Some(token) => client(token)(input) - case None => ZIO.die(AuthenticationError.NotLoggedIn) + case Some(token) => + e.mapErrorCause(client(token)(input).mapErrorCause[E] { + _.flatMap { + case ApiError.RequestFailure(error) => Cause.fail(error) + case ApiError.AuthFailure(error) => Cause.die(error) + } + }) + case None => ZIO.die(AuthenticationError.NotLoggedIn) } object ApiClientFactory: - val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = - ZLayer { - for factory <- ZIO.service[ClientEndpointFactory] - yield AuthenticatedApiClientFactory(Authentication, factory) - } - - def withAuthentication: URLayer[ + def layer: URLayer[ ClientEndpointFactory & AuthenticationService, ApiClientFactory ] = ZLayer.derive[AuthenticatedApiClientFactory] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala new file mode 100644 index 0000000..0179ab2 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiEndpoint.scala @@ -0,0 +1,8 @@ +package works.iterative.tapir + +import sttp.tapir.Endpoint +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams + +type ApiEndpoint[E, I, O] = + Endpoint[AccessToken, I, ApiError[E], O, ZioStreams] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala new file mode 100644 index 0000000..f0161c4 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiError.scala @@ -0,0 +1,9 @@ +package works.iterative.tapir + +import works.iterative.core.auth.service.AuthenticationError + +sealed trait ApiError[+ClientError] +object ApiError: + case class AuthFailure(error: AuthenticationError) extends ApiError[Nothing] + case class RequestFailure[ClientError](error: ClientError) + extends ApiError[ClientError] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala index dd52e9b..806d9d3 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/CustomTapir.scala @@ -3,12 +3,53 @@ import sttp.tapir.Tapir import sttp.tapir.json.zio.TapirJsonZio import sttp.tapir.TapirAliases +import works.iterative.core.auth.AccessToken +import sttp.capabilities.zio.ZioStreams +import works.iterative.core.auth.service.AuthenticationError +import sttp.model.StatusCode +import works.iterative.tapir.codecs.Codecs.given +import zio.* +import zio.json.* trait CustomTapir extends Tapir with TapirJsonZio with TapirAliases - with CustomTapirPlatformSpecific: - given Schema[ServerError] = Schema.derived + with CustomTapirPlatformSpecific -object CustomTapir extends CustomTapir +object CustomTapir extends CustomTapir: + type ApiError[+E] = works.iterative.tapir.ApiError[E] + val ApiError = works.iterative.tapir.ApiError + + type ApiEndpoint[E, I, O] = works.iterative.tapir.ApiEndpoint[E, I, O] + + given apiRequestFailureCodec[E: JsonCodec] + : JsonCodec[ApiError.RequestFailure[E]] = + DeriveJsonCodec.gen + given apiRequestFailureSchema[E: Schema]: Schema[ApiError.RequestFailure[E]] = + Schema.derived + + given authenticationErrorCodec: JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen + given authFailureCodec: JsonCodec[ApiError.AuthFailure] = DeriveJsonCodec.gen + given authenticationErrorSchema: Schema[AuthenticationError] = Schema.derived + given authFailureSchema: Schema[ApiError.AuthFailure] = Schema.derived + + given JsonCodec[Unit] = JsonCodec.string.transform(_ => (), _ => "") + + extension [I, O](base: Endpoint[Unit, I, Unit, O, ZioStreams]) + def toApi[E: JsonCodec: Schema]: ApiEndpoint[E, I, O] = + base + .securityIn(auth.bearer[AccessToken]()) + .errorOut( + oneOf[ApiError[E]]( + oneOfVariant[ApiError.AuthFailure]( + StatusCode.Unauthorized, + jsonBody[ApiError.AuthFailure] + ), + oneOfDefaultVariant[ApiError.RequestFailure[E]]( + statusCode(StatusCode.BadRequest) + .and(jsonBody[ApiError.RequestFailure[E]]) + ) + ) + ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala index 992feac..ce9f651 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/LiveClientEndpointFactory.scala @@ -28,7 +28,10 @@ ): m.Result = m.makeResult((securityInput: S) => (input: I) => val req = makeRequest(endpoint) - val fetch = req(securityInput)(input).followRedirects(false).send(backend) + val fetch = req(securityInput)(input) + .header("is_ajax_request", "true") + .followRedirects(false) + .send(backend) val result = for resp <- fetch.orDie diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala deleted file mode 100644 index 715591d..0000000 --- a/tapir/shared/src/main/scala/works/iterative/tapir/ServerError.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.tapir - -import zio.json.* - -sealed trait ServerError -case class InternalServerError(msg: String) extends ServerError -object InternalServerError: - def fromThrowable(t: Throwable): ServerError = InternalServerError( - t.getMessage - ) - -object ServerError: - given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala new file mode 100644 index 0000000..e120d49 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala @@ -0,0 +1,101 @@ +package works.iterative +package tapir.codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir.* +import works.iterative.core.* +import works.iterative.core.auth.* +import works.iterative.core.auth.service.AuthenticationError +import sttp.tapir.CodecFormat + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[UserMessage, T]): Either[String, T] = + v.mapError(_.id).toEither.left.map(_.mkString(",")) + + def textCodec[T]( + f: String => Validation[UserMessage, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + + given JsonCodec[PermissionOp] = + JsonCodec.string.transform(PermissionOp(_), _.value) + given JsonCodec[PermissionTarget] = textCodec(PermissionTarget.apply) + + given JsonCodec[UserId] = + JsonCodec.string.transform(UserId.unsafe(_), _.value) + + given JsonCodec[Email] = validatedStringCodec(Email) + + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) + given JsonCodec[Claim] = DeriveJsonCodec.gen[Claim] + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + + given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] + + given JsonCodec[Moment] = JsonCodec.instant.transform( + Moment(_), + _.toInstant + ) + given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] + +trait TapirCodecs: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + + given Schema[PermissionOp] = Schema.string + given Schema[PermissionTarget] = Schema.string + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[Claim] = Schema.derived[Claim] + given Schema[BasicProfile] = Schema.derived[BasicProfile] + given Schema[FileRef] = Schema.derived[FileRef] + given Schema[Moment] = + Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) + given Schema[UserHandle] = Schema.derived[UserHandle] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) + +object Codecs extends Codecs diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/AuthenticationEndpoints.scala b/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/AuthenticationEndpoints.scala new file mode 100644 index 0000000..c060d9b --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/AuthenticationEndpoints.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir +package endpoints + +import works.iterative.tapir.codecs.Codecs.given +import works.iterative.core.auth.AuthedUserInfo +import CustomTapir.* + +trait AuthenticationEndpoints(base: BaseEndpoint): + + val currentUser: Endpoint[Unit, Unit, Unit, Option[AuthedUserInfo], Any] = + base.get + .in("user" / "me") + .out(jsonBody[Option[AuthedUserInfo]]) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/RepositoryEndpointsModule.scala b/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/RepositoryEndpointsModule.scala index 69c24af..0000676 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/RepositoryEndpointsModule.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/endpoints/RepositoryEndpointsModule.scala @@ -6,6 +6,7 @@ import zio.json.JsonCodec import sttp.tapir.Schema import sttp.tapir.Endpoint +import CustomTapir.{*, given} type BaseEndpoint = Endpoint[Unit, Unit, Unit, Unit, Any] @@ -17,11 +18,13 @@ name: String, base: BaseEndpoint ) extends CustomTapir: - val load: Endpoint[Unit, K, Unit, Option[V], Any] = base.get + val load: ApiEndpoint[Unit, K, Option[V]] = base.get .in("view" / name / path[K]("id")) .out(jsonBody[Option[V]]) + .toApi - val find: Endpoint[Unit, F, Unit, List[V], Any] = base.post + val find: ApiEndpoint[Unit, F, List[V]] = base.post .in("view" / name) .in(jsonBody[F]) .out(jsonBody[List[V]]) + .toApi