From ff37bb889ef90615e827caf85a06e27436c5b8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pawlik?= Date: Tue, 21 May 2024 20:12:35 +0200 Subject: [PATCH] Treat empty scope in token response as None instead of failing (#488) * treat empty scope in token response as None instead of failing * add test for invalid content of scope --- .../oauth2/json/circe/CirceJsonDecoders.scala | 7 +++++ .../json/jsoniter/JsoniterJsonDecoders.scala | 9 +++++++ .../ocadotechnology/sttp/oauth2/common.scala | 1 + ...cessTokenResponseDeserializationSpec.scala | 24 ++++++++++++++++- ...tCredentialsTokenDeserializationSpec.scala | 27 +++++++++++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) 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 index c6a7a95d..71905a1c 100644 --- 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 @@ -4,6 +4,7 @@ import cats.syntax.all._ import org.polyvariant.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse import org.polyvariant.sttp.oauth2.UserInfo import org.polyvariant.sttp.oauth2.common.Error.OAuth2Error +import org.polyvariant.sttp.oauth2.common.Scope import org.polyvariant.sttp.oauth2.ExtendedOAuth2TokenResponse import org.polyvariant.sttp.oauth2.Introspection.Audience import org.polyvariant.sttp.oauth2.Introspection.SeqAudience @@ -26,6 +27,12 @@ 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 optionScopeDecoder: Decoder[Option[Scope]] = + Decoder.decodeOption[String].flatMap { + case Some("") => Decoder.decodeString.map[Option[Scope]](_ => None) + case _ => Decoder.decodeOption[Scope] + } + implicit val userInfoDecoder: Decoder[UserInfo] = ( Decoder[Option[String]].at("sub"), Decoder[Option[String]].at("name"), 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 index 62537511..ca60e87f 100644 --- 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 @@ -56,6 +56,15 @@ trait JsoniterJsonDecoders { Secret(reader.readString(default = null)) } + private[jsoniter] implicit val optionScopeDecoder: JsonValueCodec[Option[Scope]] = customDecoderWithDefault[Option[Scope]] { reader => + Try { + reader.readString(default = null) + }.flatMap { + case "" => Try(None) + case value => Scope.of(value).toRight(JsonDecoder.Error(s"$value is not a valid $Scope")).toTry.map(Some(_)) + } + }(None) + private[jsoniter] implicit val scopeDecoder: JsonValueCodec[Scope] = customDecoderWithDefault[Scope] { reader => Try { reader.readString(default = null) 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 7ff08ca6..ea63868a 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 @@ -22,6 +22,7 @@ object common { implicit def scopeValidate: Validate.Plain[String, ValidScope] = Validate.fromPredicate(_.matches(scopeRegex), scope => s""""$scope" matches ValidScope""", ValidScope()) + } type Scope = String Refined ValidScope 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 index 4faff622..ab6b0d81 100644 --- 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 @@ -60,7 +60,7 @@ trait ClientCredentialsAccessTokenResponseDeserializationSpec extends AnyFlatSpe ) } - "Token with empty scope" should "not be deserialized" in { + "Token with empty scope" should "be deserialized with None scope" in { val json = // language=JSON """ @@ -73,6 +73,28 @@ trait ClientCredentialsAccessTokenResponseDeserializationSpec extends AnyFlatSpe } """ + JsonDecoder[ClientCredentialsToken.AccessTokenResponse].decodeString(json).value shouldBe + ClientCredentialsToken.AccessTokenResponse( + accessToken = Secret("TAeJwlzT"), + domain = Some("mock"), + expiresIn = 2399.seconds, + scope = None + ) + } + + "Token with malformed scope" should "not be deserialized" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "scope": "déjà vu", + "token_type": "Bearer" + } + """ + 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 index ecc615e4..b4762f2a 100644 --- 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 @@ -91,6 +91,33 @@ trait ClientCredentialsTokenDeserializationSpec extends AnyFlatSpec with Matcher ) } + "token response JSON with empty scope" should "be deserialized to proper response with None scope" in { + val json = + // language=JSON + """ + { + "access_token": "TAeJwlzT", + "domain": "mock", + "expires_in": 2399, + "scope": "", + "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