From 76f7aa20c94afdbec29327590f686f1615d57c54 Mon Sep 17 00:00:00 2001 From: Jakub Wojnowski <29680262+jwojnowski@users.noreply.github.com> Date: Tue, 28 Feb 2023 12:11:21 +0100 Subject: [PATCH] =?UTF-8?q?#8=20=E2=99=BB=EF=B8=8F=20Restructure=20JSON=20?= =?UTF-8?q?deserialisation=20to=20enable=20other=20implementations=20than?= =?UTF-8?q?=20Circe=20(#351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #8 ♻️ Restructure JSON (de)serialisation to enable other implementations than Circe. * #8 💚 Add new modules to the CI. * #8 ♻️ Revert the core module and group JSON-related tests in a single trait for simplified implementation per JSON module. * #8 ♻️ Remove unused EntityDecoder. Replace codec in names with json. * #8 ♻️ Remove JsonInput.sanitize from JsonSupport and move it to the main package. * #8 🎨 Optimise imports and reformat a few files. * #8 📝 Modify docs to reflect changes in JSON deserialisation support. * #8 📝 Add backwards compatibility TL;DR regarding circe module to the changelog 👌. * ♻️ Make `EncodedJson` final. * #8 ✨ Add jsoniter-scala module. * 💚 Fix CI. * ⬆️ Bump sbt-scalajs. * ⬆️ Bump scala 3 version. * 🔧 Tweak deprecation warning configuration for jsoniter modules. Macro-generated code uses deprecated methods: ``` .../com/github/plokhotnyuk/jsoniter_scala/macros/JsonCodecMaker.scala: method isEmpty in class IterableOnceExtensionMethods is deprecated since 2.13.0: Use .iterator.isEmpty instead ``` * ♻️ Move OAuth2Error creation to common to avoid duplication in JSON implementations. --- .github/workflows/ci.yml | 10 +- build.sbt | 51 +++++- docs/client-credentials.md | 4 +- docs/contributing.md | 7 + docs/getting-started.md | 10 +- docs/json-deserialisation.md | 36 ++++ docs/migrating.md | 19 +- .../oauth2/json/circe/CirceJsonDecoders.scala | 141 +++++++++++++++ .../sttp/oauth2/json/circe/instances.scala | 3 + .../oauth2/json/circe/CirceJsonSpec.scala | 27 +++ .../json/jsoniter/JsoniterJsonDecoders.scala | 147 ++++++++++++++++ .../sttp/oauth2/json/jsoniter/instances.scala | 3 + .../json/jsoniter/JsoniterJsonSpec.scala | 25 +++ .../sttp/oauth2/AccessTokenProvider.scala | 4 +- .../sttp/oauth2/AuthorizationCode.scala | 20 +-- .../oauth2/AuthorizationCodeProvider.scala | 14 +- .../sttp/oauth2/ClientCredentials.scala | 7 +- .../oauth2/ClientCredentialsProvider.scala | 7 + .../sttp/oauth2/ClientCredentialsToken.scala | 41 +---- .../sttp/oauth2/Introspection.scala | 47 +---- .../sttp/oauth2/OAuth2Token.scala | 7 +- ...sponse.scala => OAuth2TokenResponse.scala} | 32 ---- .../sttp/oauth2/PasswordGrant.scala | 5 + .../sttp/oauth2/PasswordGrantProvider.scala | 5 + .../sttp/oauth2/RefreshTokenResponse.scala | 22 --- .../ocadotechnology/sttp/oauth2/Secret.scala | 4 - .../SttpOauth2ClientCredentialsBackend.scala | 5 + .../sttp/oauth2/TokenIntrospection.scala | 6 + .../sttp/oauth2/TokenUserDetails.scala | 17 -- .../sttp/oauth2/UserInfo.scala | 24 --- .../sttp/oauth2/UserInfoProvider.scala | 12 +- .../ocadotechnology/sttp/oauth2/circe.scala | 19 -- .../ocadotechnology/sttp/oauth2/common.scala | 41 +++-- .../sttp/oauth2/json/EncodedJson.scala | 3 + .../sttp/oauth2/json/JsonDecoder.scala | 18 ++ .../sttp/oauth2/json/SttpJsonSupport.scala | 10 ++ .../AuthorizationCodeProviderSpec.scala | 4 +- .../sttp/oauth2/AuthorizationCodeSpec.scala | 62 ++++++- ...cessTokenResponseDeserializationSpec.scala | 120 ------------- .../sttp/oauth2/ClientCredentialsSpec.scala | 164 ++++++++++++------ ...tCredentialsTokenDeserializationSpec.scala | 108 ------------ .../IntrospectionSerializationSpec.scala | 53 ------ .../sttp/oauth2/JsonDecoderMock.scala | 21 +++ .../sttp/oauth2/TokenSerializationSpec.scala | 117 ------------- .../oauth2/UserInfoSerializationSpec.scala | 113 ------------ ...cessTokenResponseDeserializationSpec.scala | 134 ++++++++++++++ ...tCredentialsTokenDeserializationSpec.scala | 124 +++++++++++++ .../json/IntrospectionSerializationSpec.scala | 90 ++++++++++ .../sttp/oauth2/json/JsonDecoders.scala | 17 ++ .../sttp/oauth2/json/JsonSpec.scala | 16 ++ .../OAuth2ErrorDeserializationSpec.scala | 136 +++++++++------ .../oauth2/json/TokenSerializationSpec.scala | 119 +++++++++++++ .../json/UserInfoSerializationSpec.scala | 121 +++++++++++++ project/plugins.sbt | 2 +- 54 files changed, 1493 insertions(+), 881 deletions(-) create mode 100644 docs/json-deserialisation.md create mode 100644 oauth2-circe/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/circe/CirceJsonDecoders.scala create mode 100644 oauth2-circe/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/circe/instances.scala create mode 100644 oauth2-circe/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/circe/CirceJsonSpec.scala create mode 100644 oauth2-jsoniter/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/JsoniterJsonDecoders.scala create mode 100644 oauth2-jsoniter/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/instances.scala create mode 100644 oauth2-jsoniter/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/JsoniterJsonSpec.scala rename oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/{Oauth2TokenResponse.scala => OAuth2TokenResponse.scala} (72%) delete mode 100644 oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/circe.scala create mode 100644 oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/EncodedJson.scala create mode 100644 oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/JsonDecoder.scala create mode 100644 oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/SttpJsonSupport.scala delete mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsAccessTokenResponseDeserializationSpec.scala delete mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsTokenDeserializationSpec.scala delete mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/IntrospectionSerializationSpec.scala create mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/JsonDecoderMock.scala delete mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/TokenSerializationSpec.scala delete mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/UserInfoSerializationSpec.scala create mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/ClientCredentialsAccessTokenResponseDeserializationSpec.scala create mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/ClientCredentialsTokenDeserializationSpec.scala create mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/IntrospectionSerializationSpec.scala create mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/JsonDecoders.scala create mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/JsonSpec.scala rename oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/{ => json}/OAuth2ErrorDeserializationSpec.scala (55%) create mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/TokenSerializationSpec.scala create mode 100644 oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/UserInfoSerializationSpec.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 462f63de..7b6c457d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.12.17, 2.13.10, 3.1.3] + scala: [2.12.17, 2.13.10, 3.2.2] java: [graalvm-ce-java11@20.3.0] runs-on: ${{ matrix.os }} steps: @@ -59,7 +59,7 @@ jobs: - run: sbt ++${{ matrix.scala }} test docs/mdoc mimaReportBinaryIssues - name: Compress target directories - run: tar cf targets.tar oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target target oauth2-cache-scalacache/target mdoc/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target + run: tar cf targets.tar oauth2-jsoniter/jvm/target oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target oauth2-jsoniter/js/target target oauth2-cache-scalacache/target mdoc/target oauth2-circe/jvm/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-circe/js/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target - name: Upload target directories uses: actions/upload-artifact@v2 @@ -120,12 +120,12 @@ jobs: tar xf targets.tar rm targets.tar - - name: Download target directories (3.1.3) + - name: Download target directories (3.2.2) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-3.1.3-${{ matrix.java }} + name: target-${{ matrix.os }}-3.2.2-${{ matrix.java }} - - name: Inflate target directories (3.1.3) + - name: Inflate target directories (3.2.2) run: | tar xf targets.tar rm targets.tar diff --git a/build.sbt b/build.sbt index 38980988..802e0a7a 100644 --- a/build.sbt +++ b/build.sbt @@ -33,7 +33,7 @@ def crossPlugin(x: sbt.librarymanagement.ModuleID) = compilerPlugin(x.cross(Cros val Scala212 = "2.12.17" val Scala213 = "2.13.10" -val Scala3 = "3.1.3" +val Scala3 = "3.2.2" val GraalVM11 = "graalvm-ce-java11@20.3.0" @@ -62,6 +62,7 @@ val Versions = new { val catsEffect = "3.3.14" val catsEffect2 = "2.5.5" val circe = "0.14.3" + val jsoniter = "2.21.1" val monix = "3.4.1" val scalaTest = "3.2.15" val sttp = "3.3.18" @@ -97,12 +98,8 @@ lazy val oauth2 = crossProject(JSPlatform, JVMPlatform) .settings( name := "sttp-oauth2", libraryDependencies ++= Seq( - "org.typelevel" %%% "cats-core" % Versions.catsCore, - "io.circe" %%% "circe-parser" % Versions.circe, - "io.circe" %%% "circe-core" % Versions.circe, - "io.circe" %%% "circe-refined" % Versions.circe, "com.softwaremill.sttp.client3" %%% "core" % Versions.sttp, - "com.softwaremill.sttp.client3" %%% "circe" % Versions.sttp, + "org.typelevel" %%% "cats-core" % Versions.catsCore, "eu.timepit" %%% "refined" % Versions.refined, "org.scalatest" %%% "scalatest" % Versions.scalaTest % Test ), @@ -114,6 +111,42 @@ lazy val oauth2 = crossProject(JSPlatform, JVMPlatform) jsSettings ) +lazy val `oauth2-circe` = crossProject(JSPlatform, JVMPlatform) + .withoutSuffixFor(JVMPlatform) + .in(file("oauth2-circe")) + .settings( + name := "sttp-oauth2-circe", + libraryDependencies ++= Seq( + "io.circe" %%% "circe-parser" % Versions.circe, + "io.circe" %%% "circe-core" % Versions.circe, + "io.circe" %%% "circe-refined" % Versions.circe + ), + mimaSettings, + compilerPlugins + ) + .jsSettings( + jsSettings + ) + .dependsOn(oauth2 % "compile->compile;test->test") + +lazy val `oauth2-jsoniter` = crossProject(JSPlatform, JVMPlatform) + .withoutSuffixFor(JVMPlatform) + .in(file("oauth2-jsoniter")) + .settings( + name := "sttp-oauth2-jsoniter", + libraryDependencies ++= Seq( + "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-core" % Versions.jsoniter, + "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % Versions.jsoniter % "compile-internal" + ), + mimaSettings, + compilerPlugins, + scalacOptions ++= Seq("-Wconf:cat=deprecation:info") // jsoniter-scala macro-generated code uses deprecated methods + ) + .jsSettings( + jsSettings + ) + .dependsOn(oauth2 % "compile->compile;test->test") + lazy val docs = project .in(file("mdoc")) // important: it must not be docs/ .settings( @@ -212,5 +245,9 @@ val root = project `oauth2-cache-ce2`, `oauth2-cache-future`.jvm, `oauth2-cache-future`.js, - `oauth2-cache-scalacache` + `oauth2-cache-scalacache`, + `oauth2-circe`.jvm, + `oauth2-circe`.js, + `oauth2-jsoniter`.jvm, + `oauth2-jsoniter`.js, ) diff --git a/docs/client-credentials.md b/docs/client-credentials.md index 04a8757f..0d9afe82 100644 --- a/docs/client-credentials.md +++ b/docs/client-credentials.md @@ -7,9 +7,11 @@ description: Client credentials grant documentation `ClientCredentials` and traits `AccessTokenProvider` and `TokenIntrospection` expose methods that: - Obtain token via `requestToken` -- `introspect` the token for it's details like `UserInfo` +- `introspect` the token for its details like `UserInfo` ```scala +import com.ocadotechnology.sttp.oauth2.json.circe.instances._ // Or your favorite JSON implementation + val accessTokenProvider = AccessTokenProvider[IO](tokenUrl, clientId, clientSecret)(backend) val tokenIntrospection = TokenIntrospection[IO](tokenIntrospectionUrl, clientId, clientSecret)(backend) val scope: Option[Scope] = Some("scope") diff --git a/docs/contributing.md b/docs/contributing.md index e2b859a5..7736c02a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -38,3 +38,10 @@ The raw documentation goes through a few steps process before the final website - The first step when building the documentation is to run `docs/mdoc/`. This step compiles the code examples, verifying if everything makes sense and is up to date. - When the build finishes, the compiled documentation ends up in `./mdoc/target/mdoc/` - The last step is to build docusaurus. Docusaurus is configured to read files from `./mdoc/target/mdoc/` and generate the website using regular docusaurus rules. + +## Adding JSON implementations +When adding a JSON implementation please follow the subsequent guidelines: +1. Each JSON implementation should exist in a separate module, not to introduce unwanted dependencies. +2. It should expose all necessary `JsonDecoder`s via a single import following the `import com.ocadotechnology.sttp.oauth2.json..instances._` convention. +3. It should make use of `com.ocadotechnology.sttp.oauth2.json.JsonSpec` test suite to ensure correctness. +4. It should be included in the documentation ([JSON Deserialisation](json-deserialisation.md)). diff --git a/docs/getting-started.md b/docs/getting-started.md index de5b26e3..d35c8b61 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,16 +8,24 @@ description: Getting started with sttp-oauth2 ## About This library aims to provide easy integration with OAuth2 providers based on [OAuth2 RFC](https://tools.ietf.org/html/rfc6749) using [sttp](https://github.com/softwaremill/sttp) client. -It uses [circe](https://github.com/circe/circe) for JSON serialization/deserialization. +There are multiple JSON implementations, see [JSON deserialisation](json-deserialisation.md) for details. ## Installation To use this library add following dependency to your `build.sbt` file ```scala "com.ocadotechnology" %% "sttp-oauth2" % "@VERSION@" +"com.ocadotechnology" %% "sttp-oauth2-circe" % "@VERSION@" // Or other, see JSON support ``` ## Usage Depending on your use case, please see documentation for the grant you want to support. Each grant is implemented in an object with explicit return and error types on methods and additionally, Tagless Final friendly `*Provider` interface. + +All grant implementations require a set of implicit `JsonDecoder`s, e.g.: +```scala +import com.ocadotechnology.sttp.oauth2.json.circe.instances._ +``` + +See [JSON deserialisation](json-deserialisation.md) for details. diff --git a/docs/json-deserialisation.md b/docs/json-deserialisation.md new file mode 100644 index 00000000..41094ada --- /dev/null +++ b/docs/json-deserialisation.md @@ -0,0 +1,36 @@ +--- +sidebar_position: 7 +description: Choosing JSON deserialisation module +--- + +# Choosing JSON deserialisation module +JSON deserialisation has been decoupled from the core modules. +There are now a couple of options to choose from: + +## circe +To use [circe](https://github.com/circe/circe) implementation +add the following module to your dependencies: + +```scala +"com.ocadotechnology" %% "sttp-oauth2-circe" % "@VERSION@" +``` + +Then import appropriate set of implicit instances: + +```scala +import com.ocadotechnology.sttp.oauth2.json.circe.instances._ +``` + +## jsoniter-scala +To use [jsoniter-scala](https://github.com/plokhotnyuk/jsoniter-scala) implementation +add the following module to your dependencies: + +```scala +"com.ocadotechnology" %% "sttp-oauth2-jsoniter" % "@VERSION@" +``` + +Then import appropriate set of implicit instances: + +```scala +import com.ocadotechnology.sttp.oauth2.json.jsoniter.instances._ +``` \ No newline at end of file diff --git a/docs/migrating.md b/docs/migrating.md index f33d5df8..421d5c1c 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 8 description: Migrations --- @@ -7,6 +7,23 @@ description: Migrations Some releases introduce breaking changes. This page aims to list those and provide migration guide. +## [v0.17.0-RC-1](https://github.com/ocadotechnology/sttp-oauth2/releases/tag/v0.17.0) + +Significant changes were introduced due to separation of JSON deserialisation from the core. Adding a module +with chosen JSON implementation is now required, as is importing an associated set of `JsonDecoder`s. + +For backwards compatibility just add `circe` module: + +```scala +"com.ocadotechnology" %% "sttp-oauth2-circe" % "0.16.0" +``` + +and a following import where you were using `sttp-oauth2`: + +```scala +import com.ocadotechnology.sttp.oauth2.json.circe.instances._ +``` + ## [v0.16.0](https://github.com/ocadotechnology/sttp-oauth2/releases/tag/v0.16.0) Minor change [#336](https://github.com/ocadotechnology/sttp-oauth2/pull/336) removed implicit parameter diff --git a/oauth2-circe/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/circe/CirceJsonDecoders.scala b/oauth2-circe/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/circe/CirceJsonDecoders.scala new file mode 100644 index 00000000..b4f8b97d --- /dev/null +++ b/oauth2-circe/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/circe/CirceJsonDecoders.scala @@ -0,0 +1,141 @@ +package com.ocadotechnology.sttp.oauth2.json.circe + +import cats.syntax.all._ +import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse +import com.ocadotechnology.sttp.oauth2.UserInfo +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.ExtendedOAuth2TokenResponse +import com.ocadotechnology.sttp.oauth2.Introspection.Audience +import com.ocadotechnology.sttp.oauth2.Introspection.SeqAudience +import com.ocadotechnology.sttp.oauth2.Introspection.StringAudience +import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse +import com.ocadotechnology.sttp.oauth2.OAuth2TokenResponse +import com.ocadotechnology.sttp.oauth2.RefreshTokenResponse +import com.ocadotechnology.sttp.oauth2.Secret +import com.ocadotechnology.sttp.oauth2.TokenUserDetails +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import io.circe.Decoder +import io.circe.refined._ + +import java.time.Instant +import scala.concurrent.duration.DurationLong +import scala.concurrent.duration.FiniteDuration + +trait CirceJsonDecoders { + + implicit def jsonDecoder[A](implicit decoder: Decoder[A]): JsonDecoder[A] = + (data: String) => io.circe.parser.decode[A](data).leftMap(error => JsonDecoder.Error(error.getMessage, cause = Some(error))) + + implicit val userInfoDecoder: Decoder[UserInfo] = ( + Decoder[Option[String]].at("sub"), + Decoder[Option[String]].at("name"), + Decoder[Option[String]].at("given_name"), + Decoder[Option[String]].at("family_name"), + Decoder[Option[String]].at("job_title"), + Decoder[Option[String]].at("domain"), + Decoder[Option[String]].at("preferred_username"), + Decoder[Option[String]].at("email"), + Decoder[Option[Boolean]].at("email_verified"), + Decoder[Option[String]].at("locale"), + Decoder[List[String]].at("sites").or(Decoder.const(List.empty[String])), + Decoder[List[String]].at("banners").or(Decoder.const(List.empty[String])), + Decoder[List[String]].at("regions").or(Decoder.const(List.empty[String])), + Decoder[List[String]].at("fulfillment_contexts").or(Decoder.const(List.empty[String])) + ).mapN(UserInfo.apply) + + implicit val secondsDecoder: Decoder[FiniteDuration] = Decoder.decodeLong.map(_.seconds) + + implicit val instantDecoder: Decoder[Instant] = Decoder.decodeLong.map(Instant.ofEpochSecond) + + implicit val tokenDecoder: Decoder[AccessTokenResponse] = + Decoder + .forProduct4( + "access_token", + "domain", + "expires_in", + "scope" + )(AccessTokenResponse.apply) + .validate { + _.downField("token_type").as[String] match { + case Right(value) if value.equalsIgnoreCase("Bearer") => List.empty + case Right(string) => List(s"Error while decoding '.token_type': value '$string' is not equal to 'Bearer'") + case Left(s) => List(s"Error while decoding '.token_type': ${s.getMessage}") + } + } + + implicit val errorDecoder: Decoder[OAuth2Error] = + Decoder.forProduct2[OAuth2Error, String, Option[String]]("error", "error_description")(OAuth2Error.fromErrorTypeAndDescription) + + implicit val tokenResponseDecoder: Decoder[OAuth2TokenResponse] = + Decoder.forProduct5( + "access_token", + "scope", + "token_type", + "expires_in", + "refresh_token" + )(OAuth2TokenResponse.apply) + + implicit val tokenUserDetailsDecoder: Decoder[TokenUserDetails] = + Decoder.forProduct7( + "username", + "name", + "forename", + "surname", + "mail", + "cn", + "sn" + )(TokenUserDetails.apply) + + implicit val extendedTokenResponseDecoder: Decoder[ExtendedOAuth2TokenResponse] = + Decoder.forProduct11( + "access_token", + "refresh_token", + "expires_in", + "user_name", + "domain", + "user_details", + "roles", + "scope", + "security_level", + "user_id", + "token_type" + )(ExtendedOAuth2TokenResponse.apply) + + implicit val audienceDecoder: Decoder[Audience] = + Decoder.decodeString.map(StringAudience.apply).or(Decoder.decodeSeq[String].map(SeqAudience.apply)) + + implicit val tokenIntrospectionResponseDecoder: Decoder[TokenIntrospectionResponse] = + Decoder.forProduct13( + "active", + "client_id", + "domain", + "exp", + "iat", + "nbf", + "authorities", + "scope", + "token_type", + "sub", + "iss", + "jti", + "aud" + )(TokenIntrospectionResponse.apply) + + implicit val refreshTokenResponseDecoder: Decoder[RefreshTokenResponse] = + Decoder.forProduct11( + "access_token", + "refresh_token", + "expires_in", + "user_name", + "domain", + "user_details", + "roles", + "scope", + "security_level", + "user_id", + "token_type" + )(RefreshTokenResponse.apply) + + implicit def secretDecoder[A: Decoder]: Decoder[Secret[A]] = Decoder[A].map(Secret(_)) + +} diff --git a/oauth2-circe/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/circe/instances.scala b/oauth2-circe/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/circe/instances.scala new file mode 100644 index 00000000..fac2a6a6 --- /dev/null +++ b/oauth2-circe/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/circe/instances.scala @@ -0,0 +1,3 @@ +package com.ocadotechnology.sttp.oauth2.json.circe + +object instances extends CirceJsonDecoders diff --git a/oauth2-circe/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/circe/CirceJsonSpec.scala b/oauth2-circe/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/circe/CirceJsonSpec.scala new file mode 100644 index 00000000..ee7f71b0 --- /dev/null +++ b/oauth2-circe/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/circe/CirceJsonSpec.scala @@ -0,0 +1,27 @@ +package com.ocadotechnology.sttp.oauth2.json.circe + +import com.ocadotechnology.sttp.oauth2.json.JsonSpec +import com.ocadotechnology.sttp.oauth2.json.circe.instances._ +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse +import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken +import com.ocadotechnology.sttp.oauth2.ExtendedOAuth2TokenResponse +import com.ocadotechnology.sttp.oauth2.RefreshTokenResponse +import com.ocadotechnology.sttp.oauth2.UserInfo + +class CirceJsonSpec extends JsonSpec { + + protected implicit def tokenIntrospectionResponseJsonDecoder: JsonDecoder[TokenIntrospectionResponse] = jsonDecoder + + protected implicit def oAuth2ErrorJsonDecoder: JsonDecoder[Error.OAuth2Error] = jsonDecoder + + protected implicit def extendedOAuth2TokenResponseJsonDecoder: JsonDecoder[ExtendedOAuth2TokenResponse] = jsonDecoder + + protected implicit def refreshTokenResponseJsonDecoder: JsonDecoder[RefreshTokenResponse] = jsonDecoder + + protected implicit def userInfoJsonDecoder: JsonDecoder[UserInfo] = jsonDecoder + + protected implicit def accessTokenResponseJsonDecoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse] = jsonDecoder + +} diff --git a/oauth2-jsoniter/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/JsoniterJsonDecoders.scala b/oauth2-jsoniter/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/JsoniterJsonDecoders.scala new file mode 100644 index 00000000..e2c67bf1 --- /dev/null +++ b/oauth2-jsoniter/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/JsoniterJsonDecoders.scala @@ -0,0 +1,147 @@ +package com.ocadotechnology.sttp.oauth2.json.jsoniter + +import cats.syntax.all._ +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse +import com.ocadotechnology.sttp.oauth2.ExtendedOAuth2TokenResponse +import com.ocadotechnology.sttp.oauth2.Introspection.Audience +import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse +import com.ocadotechnology.sttp.oauth2.OAuth2TokenResponse +import com.ocadotechnology.sttp.oauth2.RefreshTokenResponse +import com.ocadotechnology.sttp.oauth2.Secret +import com.ocadotechnology.sttp.oauth2.TokenUserDetails +import com.ocadotechnology.sttp.oauth2.UserInfo +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.Introspection.SeqAudience +import com.ocadotechnology.sttp.oauth2.Introspection.StringAudience +import com.ocadotechnology.sttp.oauth2.common.Scope +import com.ocadotechnology.sttp.oauth2.json.jsoniter.JsoniterJsonDecoders.oAuth2ErrorHelperDecoder +import com.ocadotechnology.sttp.oauth2.json.jsoniter.JsoniterJsonDecoders.tokenTypeDecoder +import com.ocadotechnology.sttp.oauth2.json.jsoniter.JsoniterJsonDecoders.IntermediateOAuth2Error + +import java.time.Instant +import scala.concurrent.duration.DurationLong +import scala.concurrent.duration.FiniteDuration +import scala.util.Failure +import scala.util.Success +import scala.util.Try +import scala.util.control.NonFatal + +trait JsoniterJsonDecoders { + + implicit def jsonDecoder[A](implicit jsonCodec: JsonValueCodec[A]): JsonDecoder[A] = + (data: String) => + Try(readFromString[A](data)) match { + case Success(value) => + Right(value) + case Failure(error: JsonDecoder.Error) => + Left(error) + case Failure(NonFatal(throwable)) => + Left(JsonDecoder.Error.fromThrowable(throwable)) + case Failure(fatal) => + throw fatal + } + + private[jsoniter] implicit val secondsDecoder: JsonValueCodec[FiniteDuration] = customDecoderFromUnsafe[FiniteDuration] { reader => + reader.readLong().seconds + } + + private[jsoniter] implicit val instantCodec: JsonValueCodec[Instant] = customDecoderFromUnsafe[Instant] { reader => + Instant.ofEpochSecond(reader.readLong()) + } + + private[jsoniter] implicit val secretDecoder: JsonValueCodec[Secret[String]] = customDecoderFromUnsafe[Secret[String]] { reader => + Secret(reader.readString(default = null)) + } + + private[jsoniter] implicit val scopeDecoder: JsonValueCodec[Scope] = customDecoderWithDefault[Scope] { reader => + Try { + reader.readString(default = null) + }.flatMap { value => + Scope.of(value).toRight(JsonDecoder.Error(s"$value is not a valid $Scope")).toTry + } + }(Scope.of("default").get) + + private val stringSequenceCodec: JsonValueCodec[List[String]] = JsonCodecMaker.make + + private[jsoniter] implicit val audienceDecoder: JsonValueCodec[Audience] = customDecoderTry[Audience] { jsonReader => + Try { + jsonReader.setMark() + StringAudience(jsonReader.readString(default = null)) + } orElse Try { + jsonReader.rollbackToMark() + SeqAudience(stringSequenceCodec.decodeValue(jsonReader, default = null)) + } + } + + private val tokenDecoderWithoutTypeValidation: JsonValueCodec[AccessTokenResponse] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + + implicit val tokenDecoderWithTypeValidation: JsonValueCodec[AccessTokenResponse] = customDecoderFromUnsafe[AccessTokenResponse] { in => + in.setMark() + val tokenType = tokenTypeDecoder.decodeValue(in, tokenTypeDecoder.nullValue) + if (tokenType.tokenType === "Bearer") { + in.rollbackToMark() + tokenDecoderWithoutTypeValidation.decodeValue(in, tokenDecoderWithoutTypeValidation.nullValue) + } else { + throw JsonDecoder.Error(s"Error while decoding '.token_type': value '$tokenType' is not equal to 'Bearer'") + } + } + + implicit val errorDecoder: JsonValueCodec[OAuth2Error] = customDecoderFromUnsafe[OAuth2Error] { in => + val IntermediateOAuth2Error(error, description) = oAuth2ErrorHelperDecoder.decodeValue(in, null) + + OAuth2Error.fromErrorTypeAndDescription(error, description) + } + + implicit val userInfoDecoder: JsonValueCodec[UserInfo] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + + implicit val tokenResponseDecoder: JsonValueCodec[OAuth2TokenResponse] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + + implicit val tokenUserDetailsDecoder: JsonValueCodec[TokenUserDetails] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + + implicit val extendedTokenResponseDecoder: JsonValueCodec[ExtendedOAuth2TokenResponse] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + + implicit val tokenIntrospectionResponseDecoder: JsonValueCodec[TokenIntrospectionResponse] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + + implicit val refreshTokenResponseDecoder: JsonValueCodec[RefreshTokenResponse] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + + private def customDecoderFromUnsafe[A](read: JsonReader => A)(implicit toNull: Null <:< A): JsonValueCodec[A] = + customDecoderTry[A](reader => Try(read(reader))) + + private def customDecoderTry[A](read: JsonReader => Try[A])(implicit toNull: Null <:< A): JsonValueCodec[A] = + customDecoderWithDefault[A](read)(toNull(null)) + + private def customDecoderWithDefault[A](read: JsonReader => Try[A])(default: A) = new JsonValueCodec[A] { + + override def decodeValue(reader: JsonReader, default: A): A = + read(reader).get + + override def encodeValue(x: A, out: JsonWriter): Unit = throw JsonDecoder.Error("Tried to encode a value using a decoder 🤷") + + override def nullValue: A = default + } + +} + +object JsoniterJsonDecoders { + + private case class TokenType(tokenType: String) + + private implicit val tokenTypeDecoder: JsonValueCodec[TokenType] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + + private case class IntermediateOAuth2Error(error: String, errorDescription: Option[String]) + + private implicit val oAuth2ErrorHelperDecoder: JsonValueCodec[IntermediateOAuth2Error] = + JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case)) + +} diff --git a/oauth2-jsoniter/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/instances.scala b/oauth2-jsoniter/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/instances.scala new file mode 100644 index 00000000..3c2566ff --- /dev/null +++ b/oauth2-jsoniter/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/instances.scala @@ -0,0 +1,3 @@ +package com.ocadotechnology.sttp.oauth2.json.jsoniter + +object instances extends JsoniterJsonDecoders diff --git a/oauth2-jsoniter/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/JsoniterJsonSpec.scala b/oauth2-jsoniter/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/JsoniterJsonSpec.scala new file mode 100644 index 00000000..e99f9af6 --- /dev/null +++ b/oauth2-jsoniter/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/jsoniter/JsoniterJsonSpec.scala @@ -0,0 +1,25 @@ +package com.ocadotechnology.sttp.oauth2.json.jsoniter + +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.json.JsonSpec +import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken +import com.ocadotechnology.sttp.oauth2.ExtendedOAuth2TokenResponse +import com.ocadotechnology.sttp.oauth2.Introspection +import com.ocadotechnology.sttp.oauth2.RefreshTokenResponse +import com.ocadotechnology.sttp.oauth2.UserInfo +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import com.ocadotechnology.sttp.oauth2.json.jsoniter.instances._ + +class JsoniterJsonSpec extends JsonSpec { + override protected implicit def tokenIntrospectionResponseJsonDecoder: JsonDecoder[Introspection.TokenIntrospectionResponse] = jsonDecoder + + override protected implicit def oAuth2ErrorJsonDecoder: JsonDecoder[OAuth2Error] = jsonDecoder + + override protected implicit def extendedOAuth2TokenResponseJsonDecoder: JsonDecoder[ExtendedOAuth2TokenResponse] = jsonDecoder + + override protected implicit def refreshTokenResponseJsonDecoder: JsonDecoder[RefreshTokenResponse] = jsonDecoder + + override protected implicit def userInfoJsonDecoder: JsonDecoder[UserInfo] = jsonDecoder + + override protected implicit def accessTokenResponseJsonDecoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse] = jsonDecoder +} diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AccessTokenProvider.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AccessTokenProvider.scala index bac5a78a..04f2659b 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AccessTokenProvider.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AccessTokenProvider.scala @@ -2,6 +2,8 @@ package com.ocadotechnology.sttp.oauth2 import cats.implicits._ import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.types.string.NonEmptyString import sttp.client3.SttpBackend import sttp.model.Uri @@ -29,7 +31,7 @@ object AccessTokenProvider { clientSecret: Secret[String] )( backend: SttpBackend[F, Any] - ): AccessTokenProvider[F] = + )(implicit decoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse], oAuth2ErrorDecoder: JsonDecoder[OAuth2Error]): AccessTokenProvider[F] = new AccessTokenProvider[F] { implicit val F: MonadError[F] = backend.responseMonad diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCode.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCode.scala index cff3df2f..02eec774 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCode.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCode.scala @@ -2,16 +2,14 @@ package com.ocadotechnology.sttp.oauth2 import cats.implicits._ import com.ocadotechnology.sttp.oauth2.common._ -import io.circe.parser.decode +import com.ocadotechnology.sttp.oauth2.AuthorizationCodeProvider.Config +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import sttp.client3._ +import sttp.model.HeaderNames import sttp.model.Uri import sttp.monad.MonadError import sttp.monad.syntax._ -import AuthorizationCodeProvider.Config -import sttp.model.HeaderNames -import io.circe.Decoder - object AuthorizationCode { private def prepareLoginLink( @@ -36,7 +34,7 @@ object AuthorizationCode { .addParam("client_id", clientId) .addParam("redirect_uri", redirectUri) - private def convertAuthCodeToUser[F[_], UriType, RT <: OAuth2TokenResponse.Basic: Decoder]( + private def convertAuthCodeToUser[F[_], UriType, RT <: OAuth2TokenResponse.Basic: JsonDecoder]( tokenUri: Uri, authCode: String, redirectUri: String, @@ -54,7 +52,7 @@ object AuthorizationCode { .response(asString) .header(HeaderNames.Accept, "application/json") } - .map(_.body.leftMap(new RuntimeException(_)).flatMap(decode[RT])) + .map(_.body.leftMap(new RuntimeException(_)).flatMap(JsonDecoder[RT].decodeString)) .flatMap(_.fold(F.error, F.unit)) } @@ -67,7 +65,7 @@ object AuthorizationCode { "code" -> authCode ) - private def performTokenRefresh[F[_], UriType, RT <: OAuth2TokenResponse.Basic: Decoder]( + private def performTokenRefresh[F[_], UriType, RT <: OAuth2TokenResponse.Basic: JsonDecoder]( tokenUri: Uri, refreshToken: String, clientId: String, @@ -84,7 +82,7 @@ object AuthorizationCode { .body(refreshTokenRequestParams(refreshToken, clientId, clientSecret.value, scopeOverride.toRequestMap)) .response(asString) } - .map(_.body.leftMap(new RuntimeException(_)).flatMap(decode[RT])) + .map(_.body.leftMap(new RuntimeException(_)).flatMap(JsonDecoder[RT].decodeString)) .flatMap(_.fold(F.error, F.unit)) } @@ -106,7 +104,7 @@ object AuthorizationCode { ): Uri = prepareLoginLink(baseUrl, clientId, redirectUri.toString, state.getOrElse(""), scopes, path.values) - def authCodeToToken[F[_], RT <: OAuth2TokenResponse.Basic: Decoder]( + def authCodeToToken[F[_], RT <: OAuth2TokenResponse.Basic: JsonDecoder]( tokenUri: Uri, redirectUri: Uri, clientId: String, @@ -126,7 +124,7 @@ object AuthorizationCode { ): Uri = prepareLogoutLink(baseUrl, clientId, postLogoutRedirect.getOrElse(redirectUri).toString(), path.values) - def refreshAccessToken[F[_], RT <: OAuth2TokenResponse.Basic: Decoder]( + def refreshAccessToken[F[_], RT <: OAuth2TokenResponse.Basic: JsonDecoder]( tokenUri: Uri, clientId: String, clientSecret: Secret[String], diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProvider.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProvider.scala index 71e7f7ec..0bbeb79a 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProvider.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProvider.scala @@ -1,12 +1,12 @@ package com.ocadotechnology.sttp.oauth2 import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.api.Refined import eu.timepit.refined.refineV import eu.timepit.refined.string.Url import sttp.client3._ import sttp.model.Uri -import io.circe.Decoder /** Provides set of functions to simplify oauth2 identity provider integration. Use the `instance` companion object method to create * instances. @@ -51,7 +51,7 @@ trait AuthorizationCodeProvider[UriType, F[_]] { * @return * TokenType details containing user info and additional information */ - def authCodeToToken[TokenType <: OAuth2TokenResponse.Basic: Decoder](authCode: String): F[TokenType] + def authCodeToToken[TokenType <: OAuth2TokenResponse.Basic: JsonDecoder](authCode: String): F[TokenType] /** Performs the token refresh on oauth2 provider nad returns new token details wrapped in effect * @@ -66,7 +66,7 @@ trait AuthorizationCodeProvider[UriType, F[_]] { * @return * TokenType details containing user info and additional information */ - def refreshAccessToken[TokenType <: OAuth2TokenResponse.Basic: Decoder]( + def refreshAccessToken[TokenType <: OAuth2TokenResponse.Basic: JsonDecoder]( refreshToken: String, scope: ScopeSelection = ScopeSelection.KeepExisting ): F[TokenType] @@ -138,7 +138,7 @@ object AuthorizationCodeProvider { .toString ) - override def authCodeToToken[TT <: OAuth2TokenResponse.Basic: Decoder](authCode: String): F[TT] = + override def authCodeToToken[TT <: OAuth2TokenResponse.Basic: JsonDecoder](authCode: String): F[TT] = AuthorizationCode .authCodeToToken[F, TT](tokenUri, redirectUri, clientId, clientSecret, authCode)(backend) @@ -149,7 +149,7 @@ object AuthorizationCodeProvider { .toString ) - override def refreshAccessToken[TT <: OAuth2TokenResponse.Basic: Decoder]( + override def refreshAccessToken[TT <: OAuth2TokenResponse.Basic: JsonDecoder]( refreshToken: String, scopeOverride: ScopeSelection = ScopeSelection.KeepExisting ): F[TT] = @@ -174,7 +174,7 @@ object AuthorizationCodeProvider { AuthorizationCode .loginLink(baseUrl, redirectUri, clientId, state, scope, pathsConfig.loginPath) - override def authCodeToToken[TT <: OAuth2TokenResponse.Basic: Decoder](authCode: String): F[TT] = + override def authCodeToToken[TT <: OAuth2TokenResponse.Basic: JsonDecoder](authCode: String): F[TT] = AuthorizationCode .authCodeToToken(tokenUri, redirectUri, clientId, clientSecret, authCode)(backend) @@ -182,7 +182,7 @@ object AuthorizationCodeProvider { AuthorizationCode .logoutLink(baseUrl, redirectUri, clientId, postLogoutRedirect, pathsConfig.logoutPath) - override def refreshAccessToken[TT <: OAuth2TokenResponse.Basic: Decoder]( + override def refreshAccessToken[TT <: OAuth2TokenResponse.Basic: JsonDecoder]( refreshToken: String, scopeOverride: ScopeSelection = ScopeSelection.KeepExisting ): F[TT] = diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentials.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentials.scala index afe045e8..9b14aed0 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentials.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentials.scala @@ -1,6 +1,9 @@ package com.ocadotechnology.sttp.oauth2 import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.types.string.NonEmptyString import sttp.client3.SttpBackend import sttp.client3.basicRequest @@ -22,7 +25,7 @@ object ClientCredentials { scope: Option[Scope] )( backend: SttpBackend[F, Any] - ): F[ClientCredentialsToken.Response] = { + )(implicit decoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse], oAuth2ErrorDecoder: JsonDecoder[OAuth2Error]): F[ClientCredentialsToken.Response] = { implicit val F: MonadError[F] = backend.responseMonad backend .send { @@ -53,7 +56,7 @@ object ClientCredentials { token: Secret[String] )( backend: SttpBackend[F, Any] - ): F[Introspection.Response] = { + )(implicit decoder: JsonDecoder[TokenIntrospectionResponse], oAuth2ErrorDecoder: JsonDecoder[OAuth2Error]): F[Introspection.Response] = { implicit val F: MonadError[F] = backend.responseMonad backend .send { diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsProvider.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsProvider.scala index bfa610fb..f96768e5 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsProvider.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsProvider.scala @@ -1,6 +1,9 @@ package com.ocadotechnology.sttp.oauth2 import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.types.string.NonEmptyString import sttp.client3.SttpBackend import sttp.model.Uri @@ -22,6 +25,10 @@ object ClientCredentialsProvider { clientSecret: Secret[String] )( backend: SttpBackend[F, Any] + )( + implicit accessTokenResponseDecoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse], + tokenIntrospectionResponseDecoder: JsonDecoder[TokenIntrospectionResponse], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] ): ClientCredentialsProvider[F] = ClientCredentialsProvider[F]( AccessTokenProvider[F](tokenUrl, clientId, clientSecret)(backend), diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsToken.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsToken.scala index 8cc55f74..4c21dab6 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsToken.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsToken.scala @@ -1,10 +1,10 @@ package com.ocadotechnology.sttp.oauth2 -import com.ocadotechnology.sttp.oauth2.common._ -import io.circe.Decoder -import io.circe.refined._ -import sttp.client3.ResponseAs +import com.ocadotechnology.sttp.oauth2.common.Error import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.common.Scope +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import sttp.client3.ResponseAs import scala.concurrent.duration.FiniteDuration @@ -12,13 +12,10 @@ object ClientCredentialsToken { type Response = Either[Error, ClientCredentialsToken.AccessTokenResponse] - private[oauth2] implicit val bearerTokenResponseDecoder: Decoder[Either[OAuth2Error, AccessTokenResponse]] = - circe.eitherOrFirstError[AccessTokenResponse, OAuth2Error]( - Decoder[AccessTokenResponse], - Decoder[OAuth2Error] - ) - - val response: ResponseAs[Response, Any] = + def response( + implicit decoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] + ): ResponseAs[Response, Any] = common.responseWithCommonError[ClientCredentialsToken.AccessTokenResponse] final case class AccessTokenResponse( @@ -28,26 +25,4 @@ object ClientCredentialsToken { scope: Option[Scope] ) - object AccessTokenResponse { - - import com.ocadotechnology.sttp.oauth2.circe._ - - implicit val tokenDecoder: Decoder[AccessTokenResponse] = - Decoder - .forProduct4( - "access_token", - "domain", - "expires_in", - "scope" - )(AccessTokenResponse.apply) - .validate { - _.downField("token_type").as[String] match { - case Right(value) if value.equalsIgnoreCase("Bearer") => List.empty - case Right(string) => List(s"Error while decoding '.token_type': value '$string' is not equal to 'Bearer'") - case Left(s) => List(s"Error while decoding '.token_type': ${s.getMessage}") - } - } - - } - } diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Introspection.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Introspection.scala index 7aba480e..c4ecba35 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Introspection.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Introspection.scala @@ -1,10 +1,8 @@ package com.ocadotechnology.sttp.oauth2 import com.ocadotechnology.sttp.oauth2.common._ -import io.circe.Codec -import io.circe.Decoder -import io.circe.Encoder -import io.circe.refined._ +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import sttp.client3.ResponseAs import java.time.Instant @@ -13,7 +11,10 @@ object Introspection { type Response = Either[common.Error, Introspection.TokenIntrospectionResponse] - val response: ResponseAs[Response, Any] = + def response( + implicit decoder: JsonDecoder[TokenIntrospectionResponse], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] + ): ResponseAs[Response, Any] = common.responseWithCommonError[TokenIntrospectionResponse] // Defined by https://datatracker.ietf.org/doc/html/rfc7662#section-2.2 with some extra fields @@ -33,44 +34,8 @@ object Introspection { aud: Option[Audience] = None ) - object TokenIntrospectionResponse { - - private implicit val instantDecoder: Decoder[Instant] = Decoder.decodeLong.map(Instant.ofEpochSecond) - - implicit val decoder: Decoder[TokenIntrospectionResponse] = - Decoder.forProduct13( - "active", - "client_id", - "domain", - "exp", - "iat", - "nbf", - "authorities", - "scope", - "token_type", - "sub", - "iss", - "jti", - "aud" - )(TokenIntrospectionResponse.apply) - - } - sealed trait Audience extends Product with Serializable final case class StringAudience(value: String) extends Audience final case class SeqAudience(value: Seq[String]) extends Audience - object Audience { - - private val encoder: Encoder[Audience] = { - case StringAudience(value) => Encoder.encodeString(value) - case SeqAudience(value) => Encoder.encodeSeq[String].apply(value) - } - - private val decoder: Decoder[Audience] = - Decoder.decodeString.map(StringAudience.apply).or(Decoder.decodeSeq[String].map(SeqAudience.apply)) - - implicit val codec: Codec[Audience] = Codec.from(decoder, encoder) - } - } diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2Token.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2Token.scala index 83c39726..520230e6 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2Token.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2Token.scala @@ -1,6 +1,8 @@ package com.ocadotechnology.sttp.oauth2 import com.ocadotechnology.sttp.oauth2.common.Error +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import sttp.client3.ResponseAs object OAuth2Token { @@ -8,7 +10,10 @@ object OAuth2Token { // TODO: should be changed to Response[A] and allow custom responses, like in AuthorizationCodeGrant type Response = Either[Error, ExtendedOAuth2TokenResponse] - val response: ResponseAs[Response, Any] = + def response( + implicit decoder: JsonDecoder[ExtendedOAuth2TokenResponse], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] + ): ResponseAs[Response, Any] = common.responseWithCommonError[ExtendedOAuth2TokenResponse] } diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Oauth2TokenResponse.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2TokenResponse.scala similarity index 72% rename from oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Oauth2TokenResponse.scala rename to oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2TokenResponse.scala index 643645aa..b6275458 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Oauth2TokenResponse.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2TokenResponse.scala @@ -1,7 +1,5 @@ package com.ocadotechnology.sttp.oauth2 -import io.circe.Decoder - import scala.concurrent.duration.FiniteDuration case class OAuth2TokenResponse( @@ -13,7 +11,6 @@ case class OAuth2TokenResponse( ) extends OAuth2TokenResponse.Basic object OAuth2TokenResponse { - import com.ocadotechnology.sttp.oauth2.circe._ /** Miminal structure as required by RFC https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 Token response is described in * https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 as follows: access_token REQUIRED. The access token issued by the @@ -36,15 +33,6 @@ object OAuth2TokenResponse { def tokenType: String } - implicit val decoder: Decoder[OAuth2TokenResponse] = - Decoder.forProduct5( - "access_token", - "scope", - "token_type", - "expires_in", - "refresh_token" - )(OAuth2TokenResponse.apply) - } // @deprecated("This model will be removed in next release", "0.10.0") @@ -61,23 +49,3 @@ case class ExtendedOAuth2TokenResponse( userId: String, tokenType: String ) extends OAuth2TokenResponse.Basic - -object ExtendedOAuth2TokenResponse { - import com.ocadotechnology.sttp.oauth2.circe._ - - implicit val decoder: Decoder[ExtendedOAuth2TokenResponse] = - Decoder.forProduct11( - "access_token", - "refresh_token", - "expires_in", - "user_name", - "domain", - "user_details", - "roles", - "scope", - "security_level", - "user_id", - "token_type" - )(ExtendedOAuth2TokenResponse.apply) - -} diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrant.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrant.scala index 5f418900..694a7c13 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrant.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrant.scala @@ -1,6 +1,8 @@ package com.ocadotechnology.sttp.oauth2 import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.types.string.NonEmptyString import sttp.client3._ import sttp.model.Uri @@ -30,6 +32,9 @@ object PasswordGrant { scope: Scope )( backend: SttpBackend[F, Any] + )( + implicit decoder: JsonDecoder[ExtendedOAuth2TokenResponse], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] ): F[OAuth2Token.Response] = { implicit val F: MonadError[F] = backend.responseMonad backend diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrantProvider.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrantProvider.scala index 7fc3e684..3ee80ab5 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrantProvider.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrantProvider.scala @@ -3,6 +3,8 @@ package com.ocadotechnology.sttp.oauth2 import cats.syntax.all._ import com.ocadotechnology.sttp.oauth2.PasswordGrant.User import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.types.string.NonEmptyString import sttp.client3.SttpBackend import sttp.model.Uri @@ -23,6 +25,9 @@ object PasswordGrantProvider { clientSecret: Secret[String] )( backend: SttpBackend[F, Any] + )( + implicit decoder: JsonDecoder[ExtendedOAuth2TokenResponse], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] ): PasswordGrantProvider[F] = { (user: User, scope: Scope) => implicit val F: MonadError[F] = backend.responseMonad PasswordGrant diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/RefreshTokenResponse.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/RefreshTokenResponse.scala index 4af82845..404fa1c9 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/RefreshTokenResponse.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/RefreshTokenResponse.scala @@ -1,7 +1,5 @@ package com.ocadotechnology.sttp.oauth2 -import io.circe.Decoder - import scala.concurrent.duration.FiniteDuration private[oauth2] final case class RefreshTokenResponse( @@ -35,23 +33,3 @@ private[oauth2] final case class RefreshTokenResponse( } -private[oauth2] object RefreshTokenResponse { - - import com.ocadotechnology.sttp.oauth2.circe._ - - implicit val decoder: Decoder[RefreshTokenResponse] = - Decoder.forProduct11( - "access_token", - "refresh_token", - "expires_in", - "user_name", - "domain", - "user_details", - "roles", - "scope", - "security_level", - "user_id", - "token_type" - )(RefreshTokenResponse.apply) - -} diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Secret.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Secret.scala index ad9f64c5..c0c51e18 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Secret.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/Secret.scala @@ -1,7 +1,5 @@ package com.ocadotechnology.sttp.oauth2 -import io.circe.Decoder - final class Secret[A] protected (val value: A) { val valueHashModulo: Int = @@ -25,6 +23,4 @@ object Secret { def apply[A](value: A) = new Secret(value) def unapply[A](secret: Secret[A]): Option[A] = Some(secret.value) - - implicit def secretDecoder[A: Decoder]: Decoder[Secret[A]] = Decoder[A].map(Secret(_)) } diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/SttpOauth2ClientCredentialsBackend.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/SttpOauth2ClientCredentialsBackend.scala index d9c0816b..a165c154 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/SttpOauth2ClientCredentialsBackend.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/SttpOauth2ClientCredentialsBackend.scala @@ -1,6 +1,8 @@ package com.ocadotechnology.sttp.oauth2 +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error import com.ocadotechnology.sttp.oauth2.common.Scope +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.types.string.NonEmptyString import sttp.capabilities.Effect import sttp.client3._ @@ -34,6 +36,9 @@ object SttpOauth2ClientCredentialsBackend { scope: Option[Scope] )( backend: SttpBackend[F, P] + )( + implicit decoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] ): SttpOauth2ClientCredentialsBackend[F, P] = { val accessTokenProvider = AccessTokenProvider[F](tokenUrl, clientId, clientSecret)(backend) SttpOauth2ClientCredentialsBackend(accessTokenProvider)(scope)(backend) diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/TokenIntrospection.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/TokenIntrospection.scala index dfac5cde..c4fdc431 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/TokenIntrospection.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/TokenIntrospection.scala @@ -2,6 +2,9 @@ package com.ocadotechnology.sttp.oauth2 import cats.syntax.all._ import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.types.string.NonEmptyString import sttp.client3.SttpBackend import sttp.model.Uri @@ -30,6 +33,9 @@ object TokenIntrospection { clientSecret: Secret[String] )( backend: SttpBackend[F, Any] + )( + implicit decoder: JsonDecoder[TokenIntrospectionResponse], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] ): TokenIntrospection[F] = new TokenIntrospection[F] { implicit val F: MonadError[F] = backend.responseMonad diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/TokenUserDetails.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/TokenUserDetails.scala index 8ef0e297..b31c02b0 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/TokenUserDetails.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/TokenUserDetails.scala @@ -1,7 +1,5 @@ package com.ocadotechnology.sttp.oauth2 -import io.circe.Decoder - case class TokenUserDetails( username: String, name: String, @@ -11,18 +9,3 @@ case class TokenUserDetails( cn: String, sn: String ) - -object TokenUserDetails { - - implicit val decoder: Decoder[TokenUserDetails] = - Decoder.forProduct7( - "username", - "name", - "forename", - "surname", - "mail", - "cn", - "sn" - )(TokenUserDetails.apply) - -} diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/UserInfo.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/UserInfo.scala index 14d2b763..01cfecc6 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/UserInfo.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/UserInfo.scala @@ -1,8 +1,5 @@ package com.ocadotechnology.sttp.oauth2 -import io.circe.Decoder -import cats.implicits._ - /** Models user info as defined in open id standard * @see * https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims @@ -53,24 +50,3 @@ final case class UserInfo( regions: List[String] = Nil, fulfillmentContexts: List[String] = Nil ) - -object UserInfo { - - implicit val decoder: Decoder[UserInfo] = ( - Decoder[Option[String]].at("sub"), - Decoder[Option[String]].at("name"), - Decoder[Option[String]].at("given_name"), - Decoder[Option[String]].at("family_name"), - Decoder[Option[String]].at("job_title"), - Decoder[Option[String]].at("domain"), - Decoder[Option[String]].at("preferred_username"), - Decoder[Option[String]].at("email"), - Decoder[Option[Boolean]].at("email_verified"), - Decoder[Option[String]].at("locale"), - Decoder[List[String]].at("sites").or(Decoder.const(List.empty[String])), - Decoder[List[String]].at("banners").or(Decoder.const(List.empty[String])), - Decoder[List[String]].at("regions").or(Decoder.const(List.empty[String])), - Decoder[List[String]].at("fulfillment_contexts").or(Decoder.const(List.empty[String])) - ).mapN(UserInfo.apply _) - -} diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/UserInfoProvider.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/UserInfoProvider.scala index 8420a32f..9b1dffa1 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/UserInfoProvider.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/UserInfoProvider.scala @@ -1,9 +1,9 @@ package com.ocadotechnology.sttp.oauth2 import cats.syntax.all._ +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.api.Refined import eu.timepit.refined.string.Url -import io.circe.parser.decode import sttp.client3._ import sttp.model.Uri import sttp.monad.MonadError @@ -21,8 +21,12 @@ object UserInfoProvider { accessToken: String )( backend: SttpBackend[F, Any] + )( + implicit userInfoDecoder: JsonDecoder[UserInfo] ): F[UserInfo] = { + implicit val F: MonadError[F] = backend.responseMonad + backend .send { basicRequest @@ -30,7 +34,7 @@ object UserInfoProvider { .header("Authorization", s"Bearer $accessToken") .response(asString) } - .map(_.body.leftMap(new RuntimeException(_)).flatMap(decode[UserInfo])) + .map(_.body.leftMap(new RuntimeException(_)).flatMap(JsonDecoder[UserInfo].decodeString)) .flatMap(_.fold(F.error, F.unit)) } @@ -39,6 +43,8 @@ object UserInfoProvider { baseUrl: Uri )( backend: SttpBackend[F, Any] + )( + implicit userInfoDecoder: JsonDecoder[UserInfo] ): UserInfoProvider[F] = (accessToken: String) => requestUserInfo(baseUrl, accessToken)(backend) @@ -47,6 +53,8 @@ object UserInfoProvider { baseUrl: String Refined Url )( backend: SttpBackend[F, Any] + )( + implicit userInfoDecoder: JsonDecoder[UserInfo] ): UserInfoProvider[F] = UserInfoProvider[F](common.refinedUrlToUri(baseUrl))(backend) } diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/circe.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/circe.scala deleted file mode 100644 index 6f95f91a..00000000 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/circe.scala +++ /dev/null @@ -1,19 +0,0 @@ -package com.ocadotechnology.sttp.oauth2 - -import io.circe.Decoder -import cats.syntax.all._ - -import scala.concurrent.duration.DurationLong -import scala.concurrent.duration.FiniteDuration - -object circe { - - implicit val decoderSeconds: Decoder[FiniteDuration] = Decoder.decodeLong.map(_.seconds) - - def eitherOrFirstError[A, B](aDecoder: Decoder[A], bDecoder: Decoder[B]): Decoder[Either[B, A]] = - aDecoder.attempt.flatMap { - case Right(a) => Decoder.const(a.asRight[B]) - case Left(e) => bDecoder.either(Decoder.failed(e)) - } - -} diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/common.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/common.scala index 05609cf4..3668ccb5 100644 --- a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/common.scala +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/common.scala @@ -2,19 +2,12 @@ package com.ocadotechnology.sttp.oauth2 import cats.syntax.all._ import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidClient -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidGrant -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidRequest -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidScope -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.UnauthorizedClient -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.UnsupportedGrantType +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import com.ocadotechnology.sttp.oauth2.json.SttpJsonSupport.asJson import eu.timepit.refined.api.Refined import eu.timepit.refined.api.RefinedTypeOps import eu.timepit.refined.api.Validate import eu.timepit.refined.string.Url -import io.circe.Decoder -import io.circe.parser.decode -import sttp.client3.circe.asJson import sttp.client3.DeserializationException import sttp.client3.HttpError import sttp.client3.ResponseAs @@ -79,26 +72,32 @@ object common { ) with OAuth2Error - implicit val errorDecoder: Decoder[OAuth2Error] = - Decoder.forProduct2[OAuth2Error, String, Option[String]]("error", "error_description") { (error, description) => - error match { - case "invalid_request" => OAuth2ErrorResponse(InvalidRequest, description) - case "invalid_client" => OAuth2ErrorResponse(InvalidClient, description) - case "invalid_grant" => OAuth2ErrorResponse(InvalidGrant, description) - case "unauthorized_client" => OAuth2ErrorResponse(UnauthorizedClient, description) - case "unsupported_grant_type" => OAuth2ErrorResponse(UnsupportedGrantType, description) - case "invalid_scope" => OAuth2ErrorResponse(InvalidScope, description) + object OAuth2Error { + + def fromErrorTypeAndDescription(errorType: String, description: Option[String]): OAuth2Error = + errorType match { + case "invalid_request" => OAuth2ErrorResponse(OAuth2ErrorResponse.InvalidRequest, description) + case "invalid_client" => OAuth2ErrorResponse(OAuth2ErrorResponse.InvalidClient, description) + case "invalid_grant" => OAuth2ErrorResponse(OAuth2ErrorResponse.InvalidGrant, description) + case "unauthorized_client" => OAuth2ErrorResponse(OAuth2ErrorResponse.UnauthorizedClient, description) + case "unsupported_grant_type" => OAuth2ErrorResponse(OAuth2ErrorResponse.UnsupportedGrantType, description) + case "invalid_scope" => OAuth2ErrorResponse(OAuth2ErrorResponse.InvalidScope, description) case unknown => UnknownOAuth2Error(unknown, description) } - } + + } } - private[oauth2] def responseWithCommonError[A](implicit decoder: Decoder[A]): ResponseAs[Either[Error, A], Any] = + private[oauth2] def responseWithCommonError[A]( + implicit decoder: JsonDecoder[A], + oAuth2ErrorDecoder: JsonDecoder[OAuth2Error] + ): ResponseAs[Either[Error, A], Any] = asJson[A].mapWithMetadata { case (either, meta) => either match { case Left(HttpError(response, statusCode)) if statusCode.isClientError => - decode[OAuth2Error](response) + JsonDecoder[OAuth2Error] + .decodeString(response) .fold(error => Error.HttpClientError(statusCode, DeserializationException(response, error)).asLeft[A], _.asLeft[A]) case Left(sttpError) => Left(Error.HttpClientError(meta.code, sttpError)) case Right(value) => value.asRight[Error] diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/EncodedJson.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/EncodedJson.scala new file mode 100644 index 00000000..e3e8a7b1 --- /dev/null +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/EncodedJson.scala @@ -0,0 +1,3 @@ +package com.ocadotechnology.sttp.oauth2.json + +final case class EncodedJson(value: String) extends AnyVal diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/JsonDecoder.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/JsonDecoder.scala new file mode 100644 index 00000000..19592a69 --- /dev/null +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/JsonDecoder.scala @@ -0,0 +1,18 @@ +package com.ocadotechnology.sttp.oauth2.json + +trait JsonDecoder[A] { + def decode(data: EncodedJson): Either[JsonDecoder.Error, A] = decodeString(data.value) + + def decodeString(data: String): Either[JsonDecoder.Error, A] +} + +object JsonDecoder { + def apply[A](implicit ev: JsonDecoder[A]): JsonDecoder[A] = ev + + case class Error(message: String, cause: Option[Throwable] = None) extends Exception(message, cause.orNull) + + object Error { + def fromThrowable(throwable: Throwable): Error = JsonDecoder.Error(throwable.getMessage, Some(throwable)) + } + +} diff --git a/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/SttpJsonSupport.scala b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/SttpJsonSupport.scala new file mode 100644 index 00000000..c099f6cc --- /dev/null +++ b/oauth2/shared/src/main/scala/com/ocadotechnology/sttp/oauth2/json/SttpJsonSupport.scala @@ -0,0 +1,10 @@ +package com.ocadotechnology.sttp.oauth2.json + +import sttp.client3.ResponseAs +import sttp.client3.ResponseException +import sttp.client3.asString + +object SttpJsonSupport { + def asJson[B: JsonDecoder]: ResponseAs[Either[ResponseException[String, JsonDecoder.Error], B], Any] = + asString.mapWithMetadata(ResponseAs.deserializeRightWithError(JsonDecoder[B].decodeString)).showAs("either(as string, as json)") +} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProviderSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProviderSpec.scala index dfcd18c8..a5e443b0 100644 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProviderSpec.scala +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProviderSpec.scala @@ -1,12 +1,12 @@ package com.ocadotechnology.sttp.oauth2 import cats.implicits._ +import com.ocadotechnology.sttp.oauth2.AuthorizationCodeProvider.Config._ import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -import sttp.model.Uri import sttp.client3.SttpBackend import sttp.client3.testing.SttpBackendStub -import AuthorizationCodeProvider.Config._ +import sttp.model.Uri class AuthorizationCodeProviderSpec extends AnyWordSpec with Matchers { diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeSpec.scala index a1fa5d6f..ec872c34 100644 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeSpec.scala +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeSpec.scala @@ -1,6 +1,7 @@ package com.ocadotechnology.sttp.oauth2 import cats.implicits._ +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import sttp.model.Uri @@ -8,6 +9,7 @@ import AuthorizationCodeProvider.Config._ import sttp.client3.testing._ import scala.util.Try import sttp.monad.TryMonad +import scala.concurrent.duration.DurationInt class AuthorizationCodeSpec extends AnyWordSpec with Matchers { @@ -129,9 +131,9 @@ class AuthorizationCodeSpec extends AnyWordSpec with Matchers { val clientSecret = Secret("secret") "decode valid extended response" in { - val testingBackend = SttpBackendStub(TryMonad) - .whenRequestMatches(_ => true) - .thenRespond(""" + val jsonResponse = + // language=JSON + """ { "access_token": "123", "refresh_token": "456", @@ -153,7 +155,37 @@ class AuthorizationCodeSpec extends AnyWordSpec with Matchers { "user_id": "", "token_type": "" } - """) + """ + + val testingBackend = SttpBackendStub(TryMonad) + .whenRequestMatches(_ => true) + .thenRespond(jsonResponse) + + implicit val decoder: JsonDecoder[ExtendedOAuth2TokenResponse] = JsonDecoderMock.partialFunction { + case `jsonResponse` => + ExtendedOAuth2TokenResponse( + Secret("secret"), + "refreshToken", + 30.seconds, + "userName", + "domain", + TokenUserDetails( + "username", + "name", + "forename", + "surname", + "mail", + "cn", + "sn" + ), + roles = Set(), + "scope", + securityLevel = 2L, + "userId", + "tokenType" + ) + } + val response = AuthorizationCode.authCodeToToken[Try, ExtendedOAuth2TokenResponse]( tokenUri, redirectUri, @@ -165,11 +197,21 @@ class AuthorizationCodeSpec extends AnyWordSpec with Matchers { } "decode valid basic response" in { + val jsonResponse = """{"access_token":"gho_16C7e42F292c6912E7710c838347Ae178B4a", "scope":"repo,gist", "token_type":"bearer"}""" + + implicit val decoder: JsonDecoder[OAuth2TokenResponse] = JsonDecoderMock.partialFunction { case `jsonResponse` => + OAuth2TokenResponse( + Secret("secret"), + "scope", + "token_type", + expiresIn = None, + refreshToken = None + ) + } + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(_ => true) - .thenRespond(""" - {"access_token":"gho_16C7e42F292c6912E7710c838347Ae178B4a", "scope":"repo,gist", "token_type":"bearer"} - """) + .thenRespond(jsonResponse) val response = AuthorizationCode.authCodeToToken[Try, OAuth2TokenResponse]( tokenUri, redirectUri, @@ -181,6 +223,8 @@ class AuthorizationCodeSpec extends AnyWordSpec with Matchers { } "fail effect with circe error on decode error" in { + implicit val decoder: JsonDecoder[OAuth2TokenResponse] = JsonDecoderMock.failing + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(_ => true) .thenRespond("{}") @@ -191,10 +235,12 @@ class AuthorizationCodeSpec extends AnyWordSpec with Matchers { clientSecret, authCode )(testingBackend) - response.toEither shouldBe a[Left[io.circe.DecodingFailure, _]] + response.toEither shouldBe a[Left[JsonDecoder.Error, _]] } "fail effect with runtime error on all other errors" in { + implicit val decoder: JsonDecoder[OAuth2TokenResponse] = JsonDecoderMock.failing + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(_ => true) .thenRespondServerError() diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsAccessTokenResponseDeserializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsAccessTokenResponseDeserializationSpec.scala deleted file mode 100644 index 8b914745..00000000 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsAccessTokenResponseDeserializationSpec.scala +++ /dev/null @@ -1,120 +0,0 @@ -package com.ocadotechnology.sttp.oauth2 - -import com.ocadotechnology.sttp.oauth2.common._ -import io.circe.DecodingFailure -import io.circe.parser._ -import org.scalatest.EitherValues -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import scala.concurrent.duration._ - -class ClientCredentialsAccessTokenResponseDeserializationSpec extends AnyFlatSpec with Matchers with EitherValues { - - "token response JSON" should "be deserialized to proper case class" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "scope": "secondapp", - "token_type": "Bearer" - }""" - - val response = decode[ClientCredentialsToken.AccessTokenResponse](json) - response shouldBe Right( - ClientCredentialsToken.AccessTokenResponse( - accessToken = Secret("TAeJwlzT"), - domain = Some("mock"), - expiresIn = 2399.seconds, - scope = Scope.of("secondapp") - ) - ) - } - - "Token with no scope" should "be deserialized" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "token_type": "Bearer" - }""" - - val response = decode[ClientCredentialsToken.AccessTokenResponse](json) - response shouldBe Right( - ClientCredentialsToken.AccessTokenResponse( - accessToken = Secret("TAeJwlzT"), - domain = Some("mock"), - expiresIn = 2399.seconds, - scope = None - ) - ) - } - - "Token with empty scope" should "not be deserialized" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "scope": "", - "token_type": "Bearer" - }""" - - decode[ClientCredentialsToken.AccessTokenResponse](json).left.value shouldBe a[DecodingFailure] - } - - "Token with wildcard scope" should "not be deserialized" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "scope": " ", - "token_type": "Bearer" - }""" - - decode[ClientCredentialsToken.AccessTokenResponse](json).left.value shouldBe a[DecodingFailure] - } - - "Token with multiple scope tokens" should "be deserialized" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "scope": "scope1 scope2", - "token_type": "Bearer" - }""" - - decode[ClientCredentialsToken.AccessTokenResponse](json).value shouldBe - ClientCredentialsToken.AccessTokenResponse( - accessToken = Secret("TAeJwlzT"), - domain = Some("mock"), - expiresIn = 2399.seconds, - scope = Scope.of("scope1 scope2") - ) - - } - - "Token with wrong type" should "not be deserialized" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "scope": "secondapp", - "token_type": "BearerToken" - }""" - - decode[ClientCredentialsToken.AccessTokenResponse](json).left.value shouldBe a[DecodingFailure] - } - -} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsSpec.scala index a182aff5..7015935e 100644 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsSpec.scala +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsSpec.scala @@ -1,22 +1,23 @@ package com.ocadotechnology.sttp.oauth2 -import com.ocadotechnology.sttp.oauth2.common.Scope import com.ocadotechnology.sttp.oauth2.common.Error - -import org.scalatest.wordspec.AnyWordSpec -import org.scalatest.matchers.should.Matchers -import sttp.model.Uri -import sttp.client3.testing._ -import sttp.monad.TryMonad -import scala.util.Try +import com.ocadotechnology.sttp.oauth2.common.Scope +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder import eu.timepit.refined.types.string.NonEmptyString -import org.scalatest.TryValues +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec import org.scalatest.EitherValues -import sttp.model.StatusCode -import sttp.model.Method +import org.scalatest.TryValues +import sttp.client3.testing._ import sttp.client3.Request +import sttp.client3.SttpBackend +import sttp.model.Method +import sttp.model.StatusCode +import sttp.model.Uri +import sttp.monad.TryMonad import scala.concurrent.duration._ +import scala.util.Try class ClientCredentialsSpec extends AnyWordSpec with Matchers with TryValues with EitherValues { @@ -44,52 +45,83 @@ class ClientCredentialsSpec extends AnyWordSpec with Matchers with TryValues wit "ClientCredentials.requestToken" should { - val requestToken = ClientCredentials.requestToken[Try](tokenUri, clientId, clientSecret, Some(scope))(_) + def requestToken( + backend: SttpBackend[Try, Any] + )( + implicit decoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse], + errorDecoder: JsonDecoder[Error.OAuth2Error] + ) = ClientCredentials.requestToken[Try](tokenUri, clientId, clientSecret, Some(scope))(backend) "successfully request token" in { + val jsonResponse = + """{ + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "scope": "secondapp", + "token_type": "Bearer" + }""" + + val expectedDecodedResponse = + ClientCredentialsToken.AccessTokenResponse( + accessToken = Secret("TAeJwlzT"), + domain = Some("mock"), + expiresIn = 2399.seconds, + scope = Scope.of("secondapp") + ) + + implicit val decoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse] = JsonDecoderMock.partialFunction { + case `jsonResponse` => expectedDecodedResponse + } + + implicit val errorDecoder: JsonDecoder[Error.OAuth2Error] = JsonDecoderMock.failing + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(validTokenRequest) .thenRespond( - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "scope": "secondapp", - "token_type": "Bearer" - }""", + jsonResponse, StatusCode.Ok ) - requestToken(testingBackend).success.value.value shouldBe ClientCredentialsToken.AccessTokenResponse( - accessToken = Secret("TAeJwlzT"), - domain = Some("mock"), - expiresIn = 2399.seconds, - scope = Scope.of("secondapp") - ) + requestToken(testingBackend).success.value.value shouldBe expectedDecodedResponse } oauth2Errors.foreach { case (errorKey, errorDescription, statusCode, error) => s"support $errorKey OAuth2 error" in { + val jsonResponse = + s""" + { + "error":"$errorKey", + "error_description":"$errorDescription" + } + """ + + val expectedDecodedResponse = Error.OAuth2ErrorResponse(error, Some(errorDescription)) + + implicit val decoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse] = JsonDecoderMock.failing + + implicit val errorDecoder: JsonDecoder[Error.OAuth2Error] = JsonDecoderMock.partialFunction { case `jsonResponse` => + expectedDecodedResponse + } + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(validTokenRequest) .thenRespond( - s""" - { - "error":"$errorKey", - "error_description":"$errorDescription" - } - """, + jsonResponse, statusCode ) - requestToken(testingBackend).success.value.left.value shouldBe Error.OAuth2ErrorResponse(error, Some(errorDescription)) + requestToken(testingBackend).success.value.left.value shouldBe expectedDecodedResponse } } "fail on unknown error" in { + implicit val decoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse] = JsonDecoderMock.failing + implicit val errorDecoder: JsonDecoder[Error.OAuth2Error] = JsonDecoderMock.failing + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(validTokenRequest) .thenRespond("""Unknown error""", StatusCode.InternalServerError) @@ -108,49 +140,81 @@ class ClientCredentialsSpec extends AnyWordSpec with Matchers with TryValues wit "ClientCredentials.introspectToken" should { - val introspectToken = ClientCredentials.introspectToken[Try](tokenIntrospectUri, clientId, clientSecret, token)(_) + def introspectToken( + backend: SttpBackend[Try, Any] + )( + implicit decoder: JsonDecoder[Introspection.TokenIntrospectionResponse], + errorDecoder: JsonDecoder[Error.OAuth2Error] + ) = ClientCredentials.introspectToken[Try](tokenIntrospectUri, clientId, clientSecret, token)(backend) "successfully introspect token" in { + val jsonResponse = + // language=JSON + s""" + { + "client_id": "$clientId", + "active": true, + "scope": "$scope" + } + """ + + val expectedDecodedResponse = Introspection.TokenIntrospectionResponse( + active = true, + clientId = Some(clientId.value), + scope = Some(scope) + ) + + implicit val decoder: JsonDecoder[Introspection.TokenIntrospectionResponse] = JsonDecoderMock.partialFunction { case `jsonResponse` => + expectedDecodedResponse + } + implicit val errorDecoder: JsonDecoder[Error.OAuth2Error] = JsonDecoderMock.failing + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(validIntrospectRequest) .thenRespond( - s"""{ - "client_id": "$clientId", - "active": true, - "scope": "$scope" - }""", + jsonResponse, StatusCode.Ok ) - introspectToken(testingBackend).success.value.value shouldBe Introspection.TokenIntrospectionResponse( - active = true, - clientId = Some(clientId.value), - scope = Some(scope) - ) + introspectToken(testingBackend).success.value.value shouldBe expectedDecodedResponse } oauth2Errors.foreach { case (errorKey, errorDescription, statusCode, error) => s"support $errorKey OAuth2 error" in { + val jsonErrorResponse = + // language=JSON + s""" + { + "error":"$errorKey", + "error_description":"$errorDescription" + } + """ + + val expectedDecodedErrorResponse = Error.OAuth2ErrorResponse(error, Some(errorDescription)) + + implicit val decoder: JsonDecoder[Introspection.TokenIntrospectionResponse] = JsonDecoderMock.failing + implicit val errorDecoder: JsonDecoder[Error.OAuth2Error] = JsonDecoderMock.partialFunction { case `jsonErrorResponse` => + expectedDecodedErrorResponse + } + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(validIntrospectRequest) .thenRespond( - s""" - { - "error":"$errorKey", - "error_description":"$errorDescription" - } - """, + jsonErrorResponse, statusCode ) - introspectToken(testingBackend).success.value.left.value shouldBe Error.OAuth2ErrorResponse(error, Some(errorDescription)) + introspectToken(testingBackend).success.value.left.value shouldBe expectedDecodedErrorResponse } } "fail on unknown error" in { + implicit val decoder: JsonDecoder[Introspection.TokenIntrospectionResponse] = JsonDecoderMock.failing + implicit val errorDecoder: JsonDecoder[Error.OAuth2Error] = JsonDecoderMock.failing + val testingBackend = SttpBackendStub(TryMonad) .whenRequestMatches(validIntrospectRequest) .thenRespond("""Unknown error""", StatusCode.InternalServerError) diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsTokenDeserializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsTokenDeserializationSpec.scala deleted file mode 100644 index 5eb5154a..00000000 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/ClientCredentialsTokenDeserializationSpec.scala +++ /dev/null @@ -1,108 +0,0 @@ -package com.ocadotechnology.sttp.oauth2 - -import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse -import com.ocadotechnology.sttp.oauth2.common._ -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidClient -import io.circe.DecodingFailure -import io.circe.parser.decode -import org.scalatest.EitherValues -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import scala.concurrent.duration._ - -class ClientCredentialsTokenDeserializationSpec extends AnyFlatSpec with Matchers with EitherValues { - - "token response JSON" should "be deserialized to proper response" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "panda_session_id": "ac097e1f-f927-41df-a776-d824f538351c", - "scope": "cfc.second-app_scope", - "token_type": "Bearer" - }""" - - val response = decode[Either[OAuth2Error, AccessTokenResponse]](json) - response shouldBe Right( - Right( - ClientCredentialsToken.AccessTokenResponse( - accessToken = Secret("TAeJwlzT"), - domain = Some("mock"), - expiresIn = 2399.seconds, - scope = Scope.of("cfc.second-app_scope") - ) - ) - ) - } - - "token response JSON without scope" should "be deserialized to proper response" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "panda_session_id": "ac097e1f-f927-41df-a776-d824f538351c", - "token_type": "Bearer" - }""" - - val response = decode[Either[OAuth2Error, AccessTokenResponse]](json) - response shouldBe Right( - Right( - ClientCredentialsToken.AccessTokenResponse( - accessToken = Secret("TAeJwlzT"), - domain = Some("mock"), - expiresIn = 2399.seconds, - scope = None - ) - ) - ) - } - - "Token with wrong type" should "not be deserialized" in { - val json = - // language=JSON - """{ - "access_token": "TAeJwlzT", - "domain": "mock", - "expires_in": 2399, - "panda_session_id": "ac097e1f-f927-41df-a776-d824f538351c", - "scope": "secondapp", - "token_type": "VeryBadType" - }""" - - decode[Either[OAuth2Error, AccessTokenResponse]](json).left.value shouldBe a[DecodingFailure] - } - - "JSON with error" should "be deserialized to proper type" in { - val json = - // language=JSON - """{ - "error": "invalid_client", - "error_description": "Client is missing or invalid.", - "error_uri": "https://pandasso.pages.tech.lastmile.com/documentation/support/panda-errors/token/#invalid_client_client_invalid" - }""" - - decode[Either[OAuth2Error, AccessTokenResponse]](json) shouldBe Right( - Left(OAuth2ErrorResponse(InvalidClient, Some("Client is missing or invalid."))) - ) - } - - "JSON with error without optional fields" should "be deserialized to proper type" in { - val json = - // language=JSON - """{ - "error": "invalid_client" - }""" - - decode[Either[OAuth2Error, AccessTokenResponse]](json) shouldBe Right( - Left(OAuth2ErrorResponse(InvalidClient, None)) - ) - } - -} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/IntrospectionSerializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/IntrospectionSerializationSpec.scala deleted file mode 100644 index b48bd3bb..00000000 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/IntrospectionSerializationSpec.scala +++ /dev/null @@ -1,53 +0,0 @@ -package com.ocadotechnology.sttp.oauth2 - -import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse -import com.ocadotechnology.sttp.oauth2.common.Scope -import io.circe.parser.decode -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec -import org.scalatest.OptionValues - -import java.time.Instant - -class IntrospectionSerializationSpec extends AnyWordSpec with Matchers with OptionValues { - "Token" should { - "deserialize token introspection response" in { - val clientId = "Client ID" - val domain = "mock" - val exp = Instant.EPOCH - val active = false - val authority1 = "aaa" - val authority2 = "bbb" - val authorities = List(authority1, authority2) - val scope = "cfc.first-app_scope" - val tokenType = "Bearer" - val audience = "Aud1" - - val json = - s"""{ - "client_id": "$clientId", - "domain": "$domain", - "exp": ${exp.getEpochSecond}, - "active": $active, - "authorities": [ "$authority1", "$authority2" ], - "scope": "$scope", - "token_type": "$tokenType", - "aud": "$audience" - }""" - - decode[TokenIntrospectionResponse](json) shouldBe Right( - TokenIntrospectionResponse( - active = active, - clientId = Some(clientId), - domain = Some(domain), - exp = Some(exp), - authorities = Some(authorities), - scope = Some(Scope.of(scope).value), - tokenType = Some(tokenType), - aud = Some(Introspection.StringAudience(audience)) - ) - ) - - } - } -} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/JsonDecoderMock.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/JsonDecoderMock.scala new file mode 100644 index 00000000..0ce6d16c --- /dev/null +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/JsonDecoderMock.scala @@ -0,0 +1,21 @@ +package com.ocadotechnology.sttp.oauth2 + +import cats.syntax.all._ +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder + +object JsonDecoderMock { + + def partialFunction[A](f: PartialFunction[String, A]): JsonDecoder[A] = new JsonDecoder[A] { + def decodeString(data: String): Either[JsonDecoder.Error, A] = + f.lift(data).fold(new JsonDecoder.Error("Data does not match").asLeft[A])(_.asRight) + } + + def failing[A] = new JsonDecoder[A] { + + def decodeString(data: String): Either[JsonDecoder.Error, A] = Left( + new JsonDecoder.Error(s"This decoder fails deliberately, even on [$data]") + ) + + } + +} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/TokenSerializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/TokenSerializationSpec.scala deleted file mode 100644 index 2b1b1de2..00000000 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/TokenSerializationSpec.scala +++ /dev/null @@ -1,117 +0,0 @@ -package com.ocadotechnology.sttp.oauth2 - -import io.circe.parser.decode -import org.scalatest.wordspec.AnyWordSpec -import org.scalatest.matchers.should.Matchers - -import scala.concurrent.duration.DurationLong - -class TokenSerializationSpec extends AnyWordSpec with Matchers { - - private val accessTokenValue = "xxxxxxxxxxxxxxxxxx" - private val accessToken = Secret(accessTokenValue) - private val expiresIn: Long = 1800 - private val userName = "john.doe" - private val domain = "exampledomain" - private val name = "John Doe" - private val forename = "John" - private val surname = "Doe" - private val mail = "john.doe@example.com" - private val role1 = "manager" - private val role2 = "user" - private val roles = Set(role1, role2) - private val scope = "" - private val securityLevel: Long = 384 - private val userId = "c0a8423e-7274-184b" - private val tokenType = "Bearer" - - "Token" should { - - "deserialize OAuth2Token" in { - val refreshToken = "yyyyyyyyyyyyyyyyyyyy" - - val jsonToken = - s"""{ - "access_token": "$accessTokenValue", - "refresh_token": "$refreshToken", - "expires_in": $expiresIn, - "user_name": "$userName", - "domain": "$domain", - "user_details": { - "username": "$userName", - "name": "$name", - "forename": "$forename", - "surname": "$surname", - "mail": "$mail", - "cn": "$name", - "sn": "$surname" - }, - "roles": [ "$role1", "$role2" ], - "scope": "$scope", - "security_level": $securityLevel, - "user_id": "$userId", - "token_type": "$tokenType" - }""" - - decode[ExtendedOAuth2TokenResponse](jsonToken) shouldBe Right( - ExtendedOAuth2TokenResponse( - accessToken, - refreshToken, - expiresIn.seconds, - userName, - domain, - TokenUserDetails(userName, name, forename, surname, mail, name, surname), - roles, - scope, - securityLevel, - userId, - tokenType - ) - ) - } - - "deserialize RefreshTokenResponse" in { - val refreshToken = None - - val jsonToken = - s"""{ - "access_token": "$accessTokenValue", - "refresh_token": null, - "expires_in": $expiresIn, - "user_name": "$userName", - "domain": "$domain", - "user_details": { - "username": "$userName", - "name": "$name", - "forename": "$forename", - "surname": "$surname", - "mail": "$mail", - "cn": "$name", - "sn": "$surname" - }, - "roles": [ "$role1", "$role2" ], - "scope": "$scope", - "security_level": $securityLevel, - "user_id": "$userId", - "token_type": "$tokenType" - }""" - - decode[RefreshTokenResponse](jsonToken) shouldBe Right( - RefreshTokenResponse( - accessToken, - refreshToken, - expiresIn.seconds, - userName, - domain, - TokenUserDetails(userName, name, forename, surname, mail, name, surname), - roles, - scope, - securityLevel, - userId, - tokenType - ) - ) - } - } - -} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/UserInfoSerializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/UserInfoSerializationSpec.scala deleted file mode 100644 index 8580a8ee..00000000 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/UserInfoSerializationSpec.scala +++ /dev/null @@ -1,113 +0,0 @@ -package com.ocadotechnology.sttp.oauth2 - -import cats.syntax.all._ -import io.circe.parser.decode -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec - -class UserInfoSerializationSpec extends AnyWordSpec with Matchers { - - "UserInfo" should { - "deserialize incomplete user info" in { - val subject = "jane.doe@ocado" - val name = "Jane Doe" - val givenName = "Jane" - val familyName = "Doe" - val domain = "ocado" - val preferredName = "jane.doe" - val email = "jane.doe@ocado.com" - val emailVerified = true - val locale = "en-GB" - val site = "c279231e-e528-4f49-8a72-490b95fa1134" - val banners = Nil - val regions = Nil - val fulfillmentContext = "97c08b89-8984-4672-a679-5cd090a605a3" - val jsonToken = - s"""{ - "sub": "$subject", - "name": "$name", - "given_name": "$givenName", - "family_name": "$familyName", - "domain": "$domain", - "preferred_username": "$preferredName", - "email": "$email", - "email_verified": $emailVerified, - "locale": "$locale", - "sites": [ "$site" ], - "fulfillment_contexts": [ "$fulfillmentContext" ] - }""" - - decode[UserInfo](jsonToken) shouldBe Right( - UserInfo( - subject.some, - name.some, - givenName.some, - familyName.some, - None, - domain.some, - preferredName.some, - email.some, - emailVerified.some, - locale.some, - List(site), - banners, - regions, - List(fulfillmentContext) - ) - ) - } - "deserialize complete user info" in { - val subject = "jane.doe@ocado" - val name = "Jane Doe" - val givenName = "Jane" - val familyName = "Doe" - val jobTitle = "Software Developer" - val domain = "ocado" - val preferredName = "jane.doe" - val email = "jane.doe@ocado.com" - val emailVerified = true - val locale = "en-GB" - val site = "c279231e-e528-4f49-8a72-490b95fa1134" - val banner = "c76bcc03-e73d-40ae-ab16-8e2ad43ca6ef" - val region = "b608c818-bdc8-4129-b76a-17bd5c66e9db" - val fulfillmentContext = "97c08b89-8984-4672-a679-5cd090a605a3" - val jsonToken = - s"""{ - "sub": "$subject", - "name": "$name", - "given_name": "$givenName", - "family_name": "$familyName", - "job_title": "$jobTitle", - "domain": "$domain", - "preferred_username": "$preferredName", - "email": "$email", - "email_verified": $emailVerified, - "locale": "$locale", - "sites": [ "$site" ], - "banners": [ "$banner" ], - "regions": [ "$region" ], - "fulfillment_contexts": [ "$fulfillmentContext" ] - }""" - - decode[UserInfo](jsonToken) shouldBe Right( - UserInfo( - subject.some, - name.some, - givenName.some, - familyName.some, - jobTitle.some, - domain.some, - preferredName.some, - email.some, - emailVerified.some, - locale.some, - List(site), - List(banner), - List(region), - List(fulfillmentContext) - ) - ) - } - } - -} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/ClientCredentialsAccessTokenResponseDeserializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/ClientCredentialsAccessTokenResponseDeserializationSpec.scala new file mode 100644 index 00000000..97c519b9 --- /dev/null +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/ClientCredentialsAccessTokenResponseDeserializationSpec.scala @@ -0,0 +1,134 @@ +package com.ocadotechnology.sttp.oauth2.json + +import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken +import com.ocadotechnology.sttp.oauth2.Secret +import com.ocadotechnology.sttp.oauth2.common._ +import org.scalatest.EitherValues +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration._ + +trait ClientCredentialsAccessTokenResponseDeserializationSpec extends AnyFlatSpec with Matchers with EitherValues { + + this: JsonDecoders => + + "token response JSON" should "be deserialized to proper case class" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "scope": "secondapp", + "token_type": "Bearer" + } + """ + + val response = JsonDecoder[ClientCredentialsToken.AccessTokenResponse].decodeString(json) + response shouldBe Right( + ClientCredentialsToken.AccessTokenResponse( + accessToken = Secret("TAeJwlzT"), + domain = Some("mock"), + expiresIn = 2399.seconds, + scope = Scope.of("secondapp") + ) + ) + } + + "Token with no scope" should "be deserialized" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "token_type": "Bearer" + } + """ + + val response = JsonDecoder[ClientCredentialsToken.AccessTokenResponse].decodeString(json) + response shouldBe Right( + ClientCredentialsToken.AccessTokenResponse( + accessToken = Secret("TAeJwlzT"), + domain = Some("mock"), + expiresIn = 2399.seconds, + scope = None + ) + ) + } + + "Token with empty scope" should "not be deserialized" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "scope": "", + "token_type": "Bearer" + } + """ + + JsonDecoder[ClientCredentialsToken.AccessTokenResponse].decodeString(json).left.value shouldBe a[JsonDecoder.Error] + } + + "Token with wildcard scope" should "not be deserialized" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "scope": " ", + "token_type": "Bearer" + } + """ + + JsonDecoder[ClientCredentialsToken.AccessTokenResponse].decodeString(json).left.value shouldBe a[JsonDecoder.Error] + } + + "Token with multiple scope tokens" should "be deserialized" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "scope": "scope1 scope2", + "token_type": "Bearer" + } + """ + + JsonDecoder[ClientCredentialsToken.AccessTokenResponse].decodeString(json).value shouldBe + ClientCredentialsToken.AccessTokenResponse( + accessToken = Secret("TAeJwlzT"), + domain = Some("mock"), + expiresIn = 2399.seconds, + scope = Scope.of("scope1 scope2") + ) + + } + + "Token with wrong type" should "not be deserialized" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "scope": "secondapp", + "token_type": "BearerToken" + } + """ + + JsonDecoder[ClientCredentialsToken.AccessTokenResponse].decodeString(json).left.value shouldBe a[JsonDecoder.Error] + } + +} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/ClientCredentialsTokenDeserializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/ClientCredentialsTokenDeserializationSpec.scala new file mode 100644 index 00000000..84949f6f --- /dev/null +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/ClientCredentialsTokenDeserializationSpec.scala @@ -0,0 +1,124 @@ +package com.ocadotechnology.sttp.oauth2 + +import cats.syntax.all._ +import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.common.Error +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidClient +import com.ocadotechnology.sttp.oauth2.json.JsonDecoders +import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import org.scalatest.EitherValues +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration._ + +trait ClientCredentialsTokenDeserializationSpec extends AnyFlatSpec with Matchers with EitherValues { + + this: JsonDecoders => + + implicit def bearerTokenResponseDecoder: JsonDecoder[Either[Error.OAuth2Error, AccessTokenResponse]] = { + def eitherOrFirstError[A, B](aDecoder: JsonDecoder[A], bDecoder: JsonDecoder[B]): JsonDecoder[Either[B, A]] = + new JsonDecoder[Either[B, A]] { + + override def decodeString(data: String): Either[JsonDecoder.Error, Either[B, A]] = + aDecoder.decodeString(data) match { + case Right(a) => a.asRight[B].asRight[JsonDecoder.Error] + case Left(firstError) => + bDecoder.decodeString(data).fold(_ => firstError.asLeft[Either[B, A]], _.asLeft[A].asRight[JsonDecoder.Error]) + } + + } + + eitherOrFirstError[AccessTokenResponse, OAuth2Error]( + JsonDecoder[AccessTokenResponse], + JsonDecoder[OAuth2Error] + ) + } + + "token response JSON" should "be deserialized to proper response" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "panda_session_id": "ac097e1f-f927-41df-a776-d824f538351c", + "scope": "cfc.second-app_scope", + "token_type": "Bearer" + } + """ + + val response = JsonDecoder[Either[OAuth2Error, AccessTokenResponse]].decodeString(json) + response shouldBe Right( + Right( + ClientCredentialsToken.AccessTokenResponse( + accessToken = Secret("TAeJwlzT"), + domain = Some("mock"), + expiresIn = 2399.seconds, + scope = Scope.of("cfc.second-app_scope") + ) + ) + ) + } + + "token response JSON without scope" should "be deserialized to proper response" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "panda_session_id": "ac097e1f-f927-41df-a776-d824f538351c", + "token_type": "Bearer" + } + """ + + val response = JsonDecoder[Either[OAuth2Error, AccessTokenResponse]].decodeString(json) + response shouldBe Right( + Right( + ClientCredentialsToken.AccessTokenResponse( + accessToken = Secret("TAeJwlzT"), + domain = Some("mock"), + expiresIn = 2399.seconds, + scope = None + ) + ) + ) + } + + "JSON with error" should "be deserialized to proper type" in { + val json = + // language=JSON + """ + { + "error": "invalid_client", + "error_description": "Client is missing or invalid.", + "error_uri": "https://pandasso.pages.tech.lastmile.com/documentation/support/panda-errors/token/#invalid_client_client_invalid" + } + """ + + JsonDecoder[Either[OAuth2Error, AccessTokenResponse]].decodeString(json) shouldBe Right( + Left(OAuth2ErrorResponse(InvalidClient, Some("Client is missing or invalid."))) + ) + } + + "JSON with error without optional fields" should "be deserialized to proper type" in { + val json = + // language=JSON + """ + { + "error": "invalid_client" + } + """ + + JsonDecoder[Either[OAuth2Error, AccessTokenResponse]].decodeString(json) shouldBe Right( + Left(OAuth2ErrorResponse(InvalidClient, None)) + ) + } + +} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/IntrospectionSerializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/IntrospectionSerializationSpec.scala new file mode 100644 index 00000000..3679feb7 --- /dev/null +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/IntrospectionSerializationSpec.scala @@ -0,0 +1,90 @@ +package com.ocadotechnology.sttp.oauth2 + +import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse +import com.ocadotechnology.sttp.oauth2.common.Scope +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import com.ocadotechnology.sttp.oauth2.json.JsonDecoders +import org.scalatest.matchers.should.Matchers +import org.scalatest.OptionValues +import org.scalatest.flatspec.AnyFlatSpecLike + +import java.time.Instant + +trait IntrospectionSerializationSpec extends AnyFlatSpecLike with Matchers with OptionValues { + this: JsonDecoders => + + val clientId = "Client ID" + val domain = "mock" + val exp = Instant.EPOCH + val active = false + val authority1 = "aaa" + val authority2 = "bbb" + val authorities = List(authority1, authority2) + val scope = "cfc.first-app_scope" + val tokenType = "Bearer" + + "Token" should "deserialize token introspection response with a string audience" in { + val audience = "Aud1" + + val json = + // language=JSON + s""" + { + "client_id": "$clientId", + "domain": "$domain", + "exp": ${exp.getEpochSecond}, + "active": $active, + "authorities": [ "$authority1", "$authority2" ], + "scope": "$scope", + "token_type": "$tokenType", + "aud": "$audience" + } + """ + + JsonDecoder[TokenIntrospectionResponse].decodeString(json) shouldBe Right( + TokenIntrospectionResponse( + active = active, + clientId = Some(clientId), + domain = Some(domain), + exp = Some(exp), + authorities = Some(authorities), + scope = Some(Scope.of(scope).value), + tokenType = Some(tokenType), + aud = Some(Introspection.StringAudience(audience)) + ) + ) + + } + + "Token" should "deserialize token introspection response with a array of audiences" in { + val audience = """["Aud1", "Aud2"]""" + + val json = + // language=JSON + s""" + { + "client_id": "$clientId", + "domain": "$domain", + "exp": ${exp.getEpochSecond}, + "active": $active, + "authorities": [ "$authority1", "$authority2" ], + "scope": "$scope", + "token_type": "$tokenType", + "aud": $audience + } + """ + + JsonDecoder[TokenIntrospectionResponse].decodeString(json) shouldBe Right( + TokenIntrospectionResponse( + active = active, + clientId = Some(clientId), + domain = Some(domain), + exp = Some(exp), + authorities = Some(authorities), + scope = Some(Scope.of(scope).value), + tokenType = Some(tokenType), + aud = Some(Introspection.SeqAudience(Seq("Aud1", "Aud2"))) + ) + ) + } +} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/JsonDecoders.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/JsonDecoders.scala new file mode 100644 index 00000000..49ae5452 --- /dev/null +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/JsonDecoders.scala @@ -0,0 +1,17 @@ +package com.ocadotechnology.sttp.oauth2.json + +import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse +import com.ocadotechnology.sttp.oauth2.common._ +import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken +import com.ocadotechnology.sttp.oauth2.ExtendedOAuth2TokenResponse +import com.ocadotechnology.sttp.oauth2.RefreshTokenResponse +import com.ocadotechnology.sttp.oauth2.UserInfo + +trait JsonDecoders { + protected implicit def tokenIntrospectionResponseJsonDecoder: JsonDecoder[TokenIntrospectionResponse] + protected implicit def oAuth2ErrorJsonDecoder: JsonDecoder[Error.OAuth2Error] + protected implicit def extendedOAuth2TokenResponseJsonDecoder: JsonDecoder[ExtendedOAuth2TokenResponse] + protected implicit def refreshTokenResponseJsonDecoder: JsonDecoder[RefreshTokenResponse] + protected implicit def userInfoJsonDecoder: JsonDecoder[UserInfo] + protected implicit def accessTokenResponseJsonDecoder: JsonDecoder[ClientCredentialsToken.AccessTokenResponse] +} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/JsonSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/JsonSpec.scala new file mode 100644 index 00000000..fbe786ee --- /dev/null +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/JsonSpec.scala @@ -0,0 +1,16 @@ +package com.ocadotechnology.sttp.oauth2.json + +import com.ocadotechnology.sttp.oauth2.ClientCredentialsTokenDeserializationSpec +import com.ocadotechnology.sttp.oauth2.IntrospectionSerializationSpec +import com.ocadotechnology.sttp.oauth2.OAuth2ErrorDeserializationSpec +import com.ocadotechnology.sttp.oauth2.TokenSerializationSpec +import com.ocadotechnology.sttp.oauth2.UserInfoSerializationSpec + +abstract class JsonSpec + extends ClientCredentialsAccessTokenResponseDeserializationSpec + with ClientCredentialsTokenDeserializationSpec + with IntrospectionSerializationSpec + with UserInfoSerializationSpec + with TokenSerializationSpec + with OAuth2ErrorDeserializationSpec + with JsonDecoders diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/OAuth2ErrorDeserializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/OAuth2ErrorDeserializationSpec.scala similarity index 55% rename from oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/OAuth2ErrorDeserializationSpec.scala rename to oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/OAuth2ErrorDeserializationSpec.scala index f887f0e3..91469963 100644 --- a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/OAuth2ErrorDeserializationSpec.scala +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/OAuth2ErrorDeserializationSpec.scala @@ -1,33 +1,37 @@ package com.ocadotechnology.sttp.oauth2 +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error +import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidClient import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidGrant import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidRequest import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.InvalidScope import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.UnauthorizedClient import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse.UnsupportedGrantType -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error -import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2ErrorResponse import com.ocadotechnology.sttp.oauth2.common.Error.UnknownOAuth2Error -import io.circe.DecodingFailure +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import com.ocadotechnology.sttp.oauth2.json.JsonDecoders import org.scalatest.EitherValues -import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.flatspec.AnyFlatSpecLike import org.scalatest.matchers.should.Matchers -import io.circe.parser.decode -class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with EitherValues { +trait OAuth2ErrorDeserializationSpec extends AnyFlatSpecLike with Matchers with EitherValues { + + this: JsonDecoders => private def check[A <: OAuth2Error](json: String, deserialized: A) = - decode[OAuth2Error](json) shouldBe Right(deserialized) + JsonDecoder[OAuth2Error].decodeString(json) shouldBe Right(deserialized) "invalid_request error JSON" should "be deserialized to InvalidRequest" in { check( // language=JSON - """{ - "error": "invalid_request", - "error_description": "Grant type is missing.", - "error_uri": "https://example.com/errors/invalid_request" - }""", + """ + { + "error": "invalid_request", + "error_description": "Grant type is missing.", + "error_uri": "https://example.com/errors/invalid_request" + } + """, OAuth2ErrorResponse(InvalidRequest, Some("Grant type is missing.")) ) } @@ -35,11 +39,13 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "invalid_client error JSON" should "be deserialized to InvalidClient" in { check( // language=JSON - """{ - "error": "invalid_client", - "error_description": "Client is missing or invalid.", - "error_uri": "https://example.com/errors/invalid_client" - }""", + """ + { + "error": "invalid_client", + "error_description": "Client is missing or invalid.", + "error_uri": "https://example.com/errors/invalid_client" + } + """, OAuth2ErrorResponse(InvalidClient, Some("Client is missing or invalid.")) ) } @@ -47,11 +53,13 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "invalid_grant error JSON" should "be deserialized to InvalidGrant" in { check( // language=JSON - """{ - "error": "invalid_grant", - "error_description": "Provided domain cannot be used with given grant type.", - "error_uri": "https://example.com/errors/invalid_grant" - }""", + """ + { + "error": "invalid_grant", + "error_description": "Provided domain cannot be used with given grant type.", + "error_uri": "https://example.com/errors/invalid_grant" + } + """, OAuth2ErrorResponse(InvalidGrant, Some("Provided domain cannot be used with given grant type.")) ) } @@ -59,11 +67,13 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "unauthorized_client error JSON" should "be deserialized to UnauthorizedClient" in { check( // language=JSON - """{ - "error": "unauthorized_client", - "error_description": "Client is not allowed to use provided grant type.", - "error_uri": "https://example.com/errors/unauthorized_client" - }""", + """ + { + "error": "unauthorized_client", + "error_description": "Client is not allowed to use provided grant type.", + "error_uri": "https://example.com/errors/unauthorized_client" + } + """, OAuth2ErrorResponse(UnauthorizedClient, Some("Client is not allowed to use provided grant type.")) ) } @@ -71,11 +81,13 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "unsupported_grant_type error JSON" should "be deserialized to InvalidGrant" in { check( // language=JSON - """{ - "error": "unsupported_grant_type", - "error_description": "Requested grant type is invalid.", - "error_uri": "https://example.com/errors/unsupported_grant_type" - }""", + """ + { + "error": "unsupported_grant_type", + "error_description": "Requested grant type is invalid.", + "error_uri": "https://example.com/errors/unsupported_grant_type" + } + """, OAuth2ErrorResponse(UnsupportedGrantType, Some("Requested grant type is invalid.")) ) } @@ -83,11 +95,13 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "invalid_scope error JSON" should "be deserialized to InvalidGrant" in { check( // language=JSON - """{ - "error": "invalid_scope", - "error_description": "Client is not allowed to use requested scope.", - "error_uri": "https://example.com/errors/invalid_scope" - }""", + """ + { + "error": "invalid_scope", + "error_description": "Client is not allowed to use requested scope.", + "error_uri": "https://example.com/errors/invalid_scope" + } + """, OAuth2ErrorResponse(InvalidScope, Some("Client is not allowed to use requested scope.")) ) } @@ -95,11 +109,13 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "invalid_token error JSON" should "be deserialized to Unknown" in { check( // language=JSON - """{ - "error": "invalid_token", - "error_description": "Invalid access token.", - "error_uri": "https://example.com/errors/invalid_token" - }""", + """ + { + "error": "invalid_token", + "error_description": "Invalid access token.", + "error_uri": "https://example.com/errors/invalid_token" + } + """, UnknownOAuth2Error(error = "invalid_token", Some("Invalid access token.")) ) } @@ -107,11 +123,13 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "insufficient_scope error JSON" should "be deserialized to Unknown" in { check( // language=JSON - """{ - "error": "insufficient_scope", - "error_description": "Access token does not contain requested scope.", - "error_uri": "https://example.com/errors/insufficient_scope" - }""", + """ + { + "error": "insufficient_scope", + "error_description": "Access token does not contain requested scope.", + "error_uri": "https://example.com/errors/insufficient_scope" + } + """, UnknownOAuth2Error(error = "insufficient_scope", Some("Access token does not contain requested scope.")) ) } @@ -119,11 +137,13 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "unknown error JSON" should "be deserialized to Unknown" in { check( // language=JSON - """{ - "error": "unknown_error", - "error_description": "I don't know this error type.", - "error_uri": "https://example.com/errors/unknown_error" - }""", + """ + { + "error": "unknown_error", + "error_description": "I don't know this error type.", + "error_uri": "https://example.com/errors/unknown_error" + } + """, UnknownOAuth2Error(error = "unknown_error", errorDescription = Some("I don't know this error type.")) ) } @@ -131,12 +151,14 @@ class OAuth2ErrorDeserializationSpec extends AnyFlatSpec with Matchers with Eith "JSON in wrong format" should "not be deserialized" in { val json = // language=JSON - """{ - "error_type": "some_error", - "description": "YOLO" - }""" + """ + { + "error_type": "some_error", + "description": "YOLO" + } + """ - decode[OAuth2Error](json).left.value shouldBe a[DecodingFailure] + JsonDecoder[OAuth2Error].decodeString(json).left.value shouldBe a[JsonDecoder.Error] } } diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/TokenSerializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/TokenSerializationSpec.scala new file mode 100644 index 00000000..5ed736a0 --- /dev/null +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/TokenSerializationSpec.scala @@ -0,0 +1,119 @@ +package com.ocadotechnology.sttp.oauth2 + +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import com.ocadotechnology.sttp.oauth2.json.JsonDecoders +import org.scalatest.flatspec.AnyFlatSpecLike +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration.DurationLong + +trait TokenSerializationSpec extends AnyFlatSpecLike with Matchers { + this: JsonDecoders => + + private val accessTokenValue = "xxxxxxxxxxxxxxxxxx" + private val accessToken = Secret(accessTokenValue) + private val expiresIn: Long = 1800 + private val userName = "john.doe" + private val domain = "exampledomain" + private val name = "John Doe" + private val forename = "John" + private val surname = "Doe" + private val mail = "john.doe@example.com" + private val role1 = "manager" + private val role2 = "user" + private val roles = Set(role1, role2) + private val scope = "" + private val securityLevel: Long = 384 + private val userId = "c0a8423e-7274-184b" + private val tokenType = "Bearer" + + "Token" should "deserialize OAuth2Token" in { + val refreshToken = "yyyyyyyyyyyyyyyyyyyy" + + val jsonToken = + s""" + { + "access_token": "$accessTokenValue", + "refresh_token": "$refreshToken", + "expires_in": $expiresIn, + "user_name": "$userName", + "domain": "$domain", + "user_details": { + "username": "$userName", + "name": "$name", + "forename": "$forename", + "surname": "$surname", + "mail": "$mail", + "cn": "$name", + "sn": "$surname" + }, + "roles": [ "$role1", "$role2" ], + "scope": "$scope", + "security_level": $securityLevel, + "user_id": "$userId", + "token_type": "$tokenType" + } + """ + + JsonDecoder[ExtendedOAuth2TokenResponse].decodeString(jsonToken) shouldBe Right( + ExtendedOAuth2TokenResponse( + accessToken, + refreshToken, + expiresIn.seconds, + userName, + domain, + TokenUserDetails(userName, name, forename, surname, mail, name, surname), + roles, + scope, + securityLevel, + userId, + tokenType + ) + ) + } + + "Token" should "deserialize RefreshTokenResponse" in { + val refreshToken = None + + val jsonToken = + s""" + { + "access_token": "$accessTokenValue", + "refresh_token": null, + "expires_in": $expiresIn, + "user_name": "$userName", + "domain": "$domain", + "user_details": { + "username": "$userName", + "name": "$name", + "forename": "$forename", + "surname": "$surname", + "mail": "$mail", + "cn": "$name", + "sn": "$surname" + }, + "roles": [ "$role1", "$role2" ], + "scope": "$scope", + "security_level": $securityLevel, + "user_id": "$userId", + "token_type": "$tokenType" + } + """ + + JsonDecoder[RefreshTokenResponse].decodeString(jsonToken) shouldBe Right( + RefreshTokenResponse( + accessToken, + refreshToken, + expiresIn.seconds, + userName, + domain, + TokenUserDetails(userName, name, forename, surname, mail, name, surname), + roles, + scope, + securityLevel, + userId, + tokenType + ) + ) + } +} diff --git a/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/UserInfoSerializationSpec.scala b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/UserInfoSerializationSpec.scala new file mode 100644 index 00000000..d6daba6f --- /dev/null +++ b/oauth2/shared/src/test/scala/com/ocadotechnology/sttp/oauth2/json/UserInfoSerializationSpec.scala @@ -0,0 +1,121 @@ +package com.ocadotechnology.sttp.oauth2 + +import cats.syntax.all._ +import com.ocadotechnology.sttp.oauth2.json.JsonDecoder +import com.ocadotechnology.sttp.oauth2.json.JsonDecoders +import org.scalatest.flatspec.AnyFlatSpecLike +import org.scalatest.matchers.should.Matchers + +trait UserInfoSerializationSpec extends AnyFlatSpecLike with Matchers { + + this: JsonDecoders => + + "UserInfo" should "deserialize incomplete user info" in { + val subject = "jane.doe@ocado" + val name = "Jane Doe" + val givenName = "Jane" + val familyName = "Doe" + val domain = "ocado" + val preferredName = "jane.doe" + val email = "jane.doe@ocado.com" + val emailVerified = true + val locale = "en-GB" + val site = "c279231e-e528-4f49-8a72-490b95fa1134" + val banners = Nil + val regions = Nil + val fulfillmentContext = "97c08b89-8984-4672-a679-5cd090a605a3" + val jsonToken = + // language=JSON + s""" + { + "sub": "$subject", + "name": "$name", + "given_name": "$givenName", + "family_name": "$familyName", + "domain": "$domain", + "preferred_username": "$preferredName", + "email": "$email", + "email_verified": $emailVerified, + "locale": "$locale", + "sites": [ "$site" ], + "fulfillment_contexts": [ "$fulfillmentContext" ] + } + """ + + JsonDecoder[UserInfo].decodeString(jsonToken) shouldBe Right( + UserInfo( + subject.some, + name.some, + givenName.some, + familyName.some, + None, + domain.some, + preferredName.some, + email.some, + emailVerified.some, + locale.some, + List(site), + banners, + regions, + List(fulfillmentContext) + ) + ) + } + + "UserInfo" should "deserialize complete user info" in { + val subject = "jane.doe@ocado" + val name = "Jane Doe" + val givenName = "Jane" + val familyName = "Doe" + val jobTitle = "Software Developer" + val domain = "ocado" + val preferredName = "jane.doe" + val email = "jane.doe@ocado.com" + val emailVerified = true + val locale = "en-GB" + val site = "c279231e-e528-4f49-8a72-490b95fa1134" + val banner = "c76bcc03-e73d-40ae-ab16-8e2ad43ca6ef" + val region = "b608c818-bdc8-4129-b76a-17bd5c66e9db" + val fulfillmentContext = "97c08b89-8984-4672-a679-5cd090a605a3" + val jsonToken = + // language=JSON + s""" + { + "sub": "$subject", + "name": "$name", + "given_name": "$givenName", + "family_name": "$familyName", + "job_title": "$jobTitle", + "domain": "$domain", + "preferred_username": "$preferredName", + "email": "$email", + "email_verified": $emailVerified, + "locale": "$locale", + "sites": [ "$site" ], + "banners": [ "$banner" ], + "regions": [ "$region" ], + "fulfillment_contexts": [ "$fulfillmentContext" ] + } + """ + + JsonDecoder[UserInfo].decodeString(jsonToken) shouldBe Right( + UserInfo( + subject.some, + name.some, + givenName.some, + familyName.some, + jobTitle.some, + domain.some, + preferredName.some, + email.some, + emailVerified.some, + locale.some, + List(site), + List(banner), + List(region), + List(fulfillmentContext) + ) + ) + } + +} diff --git a/project/plugins.sbt b/project/plugins.sbt index c5ed0dfb..6baff1e5 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -5,4 +5,4 @@ addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.1") addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.7") addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1") addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.2.0") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.11.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0")