diff --git a/build.sbt b/build.sbt index 15a3774b..86af48d8 100644 --- a/build.sbt +++ b/build.sbt @@ -76,8 +76,14 @@ val testDependencies = Seq( "io.circe" %% "circe-literal" % Versions.circe ).map(_ % Test) -val mimaSettings = - mimaPreviousArtifacts := previousStableVersion.value.map(organization.value %% moduleName.value % _).toSet +val mimaSettings = + mimaPreviousArtifacts := { + val onlyPatchChanged = previousStableVersion.value.flatMap(CrossVersion.partialVersion) == CrossVersion.partialVersion(version.value) + if(onlyPatchChanged) + previousStableVersion.value.map(organization.value %% moduleName.value % _).toSet + else + Set.empty + } lazy val oauth2 = project.settings( name := "sttp-oauth2", diff --git a/docs/authorization-code.md b/docs/authorization-code.md index adf884d0..032194b4 100644 --- a/docs/authorization-code.md +++ b/docs/authorization-code.md @@ -5,7 +5,19 @@ description: Authorization code grant documentation # Authorization code grant +## Methods + `AuthorizationCode` and `AuthorizationCodeProvider` - provide functionality for: - generating _login_ and _logout_ redirect links, - `authCodeToToken` for converting authorization code to token, - `refreshAccessToken` for performing a token refresh request + +## Token types + +`authCodeToToken` and `refreshAccessToken` require `RT <: OAuth2TokenResponse.Basic: Decoder` type parameter, that describes desired. response structure. You can use `OAuth2TokenResponse`, `ExtendedOAuth2TokenResponse` or roll your own type that matches the type bounds. + +## Configuration + +OAuth2 doesn't precisely define urls for used for the process. Those differ by provider. +`AuthorizationCodeProvider.Config` provides a structure for configuring the endpoints. +For login with GitHub you can use `AuthorizationCodeProvider.Config.GitHub`. Feel free to issue a PR if you want any other well-known provider supported. diff --git a/docs/migrating.md b/docs/migrating.md new file mode 100644 index 00000000..5d015f03 --- /dev/null +++ b/docs/migrating.md @@ -0,0 +1,21 @@ +--- +sidebar_position: 6 +description: Migrations +--- + +# Migrating to newer versions + +Some releases introduce breaking changes. This page aims to list those and provide migration guide. + + +## [v0.10.0](https://github.com/ocadotechnology/sttp-oauth2/releases/tag/v0.5.0) + +`authCodeToToken` and `refreshAccessToken` no longer return fixed token response type. Instead, they require `RT <: OAuth2TokenResponse.Basic: Decoder` type parameter, that describes desired. response structure. + +There are two matching pre-defined types provided: +- `OAuth2TokenResponse` - minimal response as described by [rfc6749](https://datatracker.ietf.org/doc/html/rfc6749#section-5.1) +- `ExtendedOAuth2TokenResponse` - previously known as `Oauth2TokenResponse`, the previously fixed response type. Use this for backward compatiblity. + +## [v0.5.0](https://github.com/ocadotechnology/sttp-oauth2/releases/tag/v0.5.0) + +This version introduces [sttp3](https://github.com/ocadotechnology/sttp-oauth2/pull/39). Please see [sttp v3.0.0 release](https://github.com/softwaremill/sttp/releases/tag/v3.0.0) for migration guide. \ No newline at end of file diff --git a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCode.scala b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCode.scala index 594b1f95..88b87d69 100644 --- a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCode.scala +++ b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCode.scala @@ -1,6 +1,6 @@ package com.ocadotechnology.sttp.oauth2 -import cats.syntax.all._ +import cats.implicits._ import com.ocadotechnology.sttp.oauth2.common._ import io.circe.parser.decode import sttp.client3._ @@ -10,6 +10,7 @@ import sttp.monad.syntax._ import AuthorizationCodeProvider.Config import sttp.model.HeaderNames +import io.circe.Decoder object AuthorizationCode { @@ -35,7 +36,7 @@ object AuthorizationCode { .addParam("client_id", clientId) .addParam("redirect_uri", redirectUri) - private def convertAuthCodeToUser[F[_], UriType]( + private def convertAuthCodeToUser[F[_], UriType, RT <: OAuth2TokenResponse.Basic: Decoder]( tokenUri: Uri, authCode: String, redirectUri: String, @@ -43,8 +44,8 @@ object AuthorizationCode { clientSecret: Secret[String] )( implicit backend: SttpBackend[F, Any] - ): F[Oauth2TokenResponse] = { - implicit val F: MonadError[F] = backend.responseMonad + ): F[RT] = { + implicit val ME: MonadError[F] = backend.responseMonad backend .send { basicRequest @@ -53,8 +54,15 @@ object AuthorizationCode { .response(asString) .header(HeaderNames.Accept, "application/json") } - .map(_.body.leftMap(new RuntimeException(_)).flatMap(decode[Oauth2TokenResponse]).toTry) - .flatMap(backend.responseMonad.fromTry) + .flatMap{ response => + ME.fromTry( + response + .body + .leftMap(new RuntimeException(_)) + .flatMap(decode[RT]) + .toTry + ) + } } private def tokenRequestParams(authCode: String, redirectUri: String, clientId: String, clientSecret: String) = @@ -66,7 +74,7 @@ object AuthorizationCode { "code" -> authCode ) - private def performTokenRefresh[F[_], UriType]( + private def performTokenRefresh[F[_], UriType, RT <: OAuth2TokenResponse.Basic: Decoder]( tokenUri: Uri, refreshToken: String, clientId: String, @@ -74,7 +82,7 @@ object AuthorizationCode { scopeOverride: ScopeSelection )( implicit backend: SttpBackend[F, Any] - ): F[Oauth2TokenResponse] = { + ): F[RT] = { implicit val F: MonadError[F] = backend.responseMonad backend .send { @@ -83,8 +91,7 @@ object AuthorizationCode { .body(refreshTokenRequestParams(refreshToken, clientId, clientSecret.value, scopeOverride.toRequestMap)) .response(asString) } - .map(_.body.leftMap(new RuntimeException(_)).flatMap(decode[RefreshTokenResponse]).toTry) - .map(_.map(_.toOauth2Token(refreshToken))) + .map(_.body.leftMap(new RuntimeException(_)).flatMap(decode[RT]).toTry) .flatMap(backend.responseMonad.fromTry) } @@ -106,7 +113,7 @@ object AuthorizationCode { ): Uri = prepareLoginLink(baseUrl, clientId, redirectUri.toString, state.getOrElse(""), scopes, path.values) - def authCodeToToken[F[_]]( + def authCodeToToken[F[_], RT <: OAuth2TokenResponse.Basic: Decoder]( tokenUri: Uri, redirectUri: Uri, clientId: String, @@ -114,8 +121,8 @@ object AuthorizationCode { authCode: String )( implicit backend: SttpBackend[F, Any] - ): F[Oauth2TokenResponse] = - convertAuthCodeToUser(tokenUri, authCode, redirectUri.toString, clientId, clientSecret) + ): F[RT] = + convertAuthCodeToUser[F, Uri, RT](tokenUri, authCode, redirectUri.toString, clientId, clientSecret) def logoutLink[F[_]]( baseUrl: Uri, @@ -126,7 +133,7 @@ object AuthorizationCode { ): Uri = prepareLogoutLink(baseUrl, clientId, postLogoutRedirect.getOrElse(redirectUri).toString(), path.values) - def refreshAccessToken[F[_]]( + def refreshAccessToken[F[_], RT <: OAuth2TokenResponse.Basic: Decoder]( tokenUri: Uri, clientId: String, clientSecret: Secret[String], @@ -134,7 +141,7 @@ object AuthorizationCode { scopeOverride: ScopeSelection = ScopeSelection.KeepExisting )( implicit backend: SttpBackend[F, Any] - ): F[Oauth2TokenResponse] = - performTokenRefresh(tokenUri, refreshToken, clientId, clientSecret, scopeOverride) + ): F[RT] = + performTokenRefresh[F, Uri, RT](tokenUri, refreshToken, clientId, clientSecret, scopeOverride) } diff --git a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProvider.scala b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProvider.scala index 859614a1..c2f5f7f4 100644 --- a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProvider.scala +++ b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeProvider.scala @@ -6,6 +6,7 @@ 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. @@ -36,21 +37,25 @@ trait AuthorizationCodeProvider[UriType, F[_]] { /** Returns token details wrapped in effect * + * @tparam TokenType type that models token response. It must implement MinimalStructurem, and have io.circe.Decoder instance. + * Predefined implementations: OAuth2TokenResponse and ExtendedOAuth2TokenResponse * @param authCode code provided by oauth2 provider redirect, * after user is authenticated correctly - * @return Oauth2TokenResponse details containing user info and additional information + * @return TokenType details containing user info and additional information */ - def authCodeToToken(authCode: String): F[Oauth2TokenResponse] + def authCodeToToken[TokenType <: OAuth2TokenResponse.Basic: Decoder](authCode: String): F[TokenType] /** Performs the token refresh on oauth2 provider nad returns new token details wrapped in effect * + * @tparam TokenType type that models token response. It must implement MinimalStructurem, and have io.circe.Decoder instance. + * Predefined implementations: OAuth2TokenResponse and ExtendedOAuth2TokenResponse * @param refreshToken value from refresh_token field of previous access token * @param scope optional parameter for overriding token scope, useful to narrow down the scope * when not provided or ScopeSelection.KeepExisting passed, * the new token will be issued for the same scope as the previous one - * @return Oauth2TokenResponse details containing user info and additional information + * @return TokenType details containing user info and additional information */ - def refreshAccessToken(refreshToken: String, scope: ScopeSelection = ScopeSelection.KeepExisting): F[Oauth2TokenResponse] + def refreshAccessToken[TokenType <: OAuth2TokenResponse.Basic: Decoder](refreshToken: String, scope: ScopeSelection = ScopeSelection.KeepExisting): F[TokenType] } object AuthorizationCodeProvider { @@ -81,6 +86,12 @@ object AuthorizationCodeProvider { tokenPath = Path(List(Segment("oauth2"), Segment("token"))) ) + val GitHub = Config( + loginPath = Path(List(Segment("login"), Segment("oauth"), Segment("authorize"))), + logoutPath = Path(List(Segment("logout"))), + tokenPath = Path(List(Segment("login"), Segment("oauth"), Segment("access_token"))) + ) + // Other predefined configurations for well-known oauth2 providers could be placed here } @@ -106,9 +117,9 @@ object AuthorizationCodeProvider { .toString ) - override def authCodeToToken(authCode: String): F[Oauth2TokenResponse] = + override def authCodeToToken[TT <: OAuth2TokenResponse.Basic: Decoder](authCode: String): F[TT] = AuthorizationCode - .authCodeToToken(tokenUri, redirectUri, clientId, clientSecret, authCode) + .authCodeToToken[F, TT](tokenUri, redirectUri, clientId, clientSecret, authCode) override def logoutLink(postLogoutRedirect: Option[Refined[String, Url]]): Refined[String, Url] = refineV[Url].unsafeFrom[String]( @@ -117,10 +128,10 @@ object AuthorizationCodeProvider { .toString ) - override def refreshAccessToken( + override def refreshAccessToken[TT <: OAuth2TokenResponse.Basic: Decoder]( refreshToken: String, scopeOverride: ScopeSelection = ScopeSelection.KeepExisting - ): F[Oauth2TokenResponse] = + ): F[TT] = AuthorizationCode .refreshAccessToken(tokenUri, clientId, clientSecret, refreshToken, scopeOverride) @@ -142,7 +153,7 @@ object AuthorizationCodeProvider { AuthorizationCode .loginLink(baseUrl, redirectUri, clientId, state, scope, pathsConfig.loginPath) - override def authCodeToToken(authCode: String): F[Oauth2TokenResponse] = + override def authCodeToToken[TT <: OAuth2TokenResponse.Basic: Decoder](authCode: String): F[TT] = AuthorizationCode .authCodeToToken(tokenUri, redirectUri, clientId, clientSecret, authCode) @@ -150,10 +161,10 @@ object AuthorizationCodeProvider { AuthorizationCode .logoutLink(baseUrl, redirectUri, clientId, postLogoutRedirect, pathsConfig.logoutPath) - override def refreshAccessToken( + override def refreshAccessToken[TT <: OAuth2TokenResponse.Basic: Decoder]( refreshToken: String, scopeOverride: ScopeSelection = ScopeSelection.KeepExisting - ): F[Oauth2TokenResponse] = + ): F[TT] = AuthorizationCode .refreshAccessToken(tokenUri, clientId, clientSecret, refreshToken, scopeOverride) diff --git a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2Token.scala b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2Token.scala index dc6e9318..fddfa633 100644 --- a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2Token.scala +++ b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/OAuth2Token.scala @@ -7,15 +7,16 @@ import com.ocadotechnology.sttp.oauth2.common.Error.OAuth2Error object OAuth2Token { - type Response = Either[Error, Oauth2TokenResponse] + // TODO: should be changed to Response[A] and allow custom responses, like in AuthorizationCodeGrant + type Response = Either[Error, ExtendedOAuth2TokenResponse] - private implicit val bearerTokenResponseDecoder: Decoder[Either[OAuth2Error, Oauth2TokenResponse]] = - circe.eitherOrFirstError[Oauth2TokenResponse, OAuth2Error]( - Decoder[Oauth2TokenResponse], + private implicit val bearerTokenResponseDecoder: Decoder[Either[OAuth2Error, ExtendedOAuth2TokenResponse]] = + circe.eitherOrFirstError[ExtendedOAuth2TokenResponse, OAuth2Error]( + Decoder[ExtendedOAuth2TokenResponse], Decoder[OAuth2Error] ) val response: ResponseAs[Response, Any] = - common.responseWithCommonError[Oauth2TokenResponse] + common.responseWithCommonError[ExtendedOAuth2TokenResponse] } diff --git a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/Oauth2TokenResponse.scala b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/Oauth2TokenResponse.scala index e0c31f8f..5fe79faa 100644 --- a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/Oauth2TokenResponse.scala +++ b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/Oauth2TokenResponse.scala @@ -4,7 +4,61 @@ import io.circe.Decoder import scala.concurrent.duration.FiniteDuration -case class Oauth2TokenResponse( +case class OAuth2TokenResponse( + accessToken: Secret[String], + scope: String, + tokenType: String, + expiresIn: Option[FiniteDuration], + refreshToken: Option[String] +) 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 authorization server. + * + *token_type + * REQUIRED. The type of the token issued as described in + * Section 7.1. Value is case insensitive. + * + *expires_in + * RECOMMENDED. The lifetime in seconds of the access token. For + * example, the value "3600" denotes that the access token will + * expire in one hour from the time the response was generated. + * If omitted, the authorization server SHOULD provide the + * expiration time via other means or document the default value. + * + *refresh_token + * OPTIONAL. The refresh token, which can be used to obtain new + * access tokens using the same authorization grant as described + * in Section 6. + * + *scope + * OPTIONAL, if identical to the scope requested by the client; + * otherwise, REQUIRED. The scope of the access token as + * described by Section 3.3. + */ + trait Basic { + def accessToken: Secret[String] + 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") +case class ExtendedOAuth2TokenResponse( accessToken: Secret[String], refreshToken: String, expiresIn: FiniteDuration, @@ -16,12 +70,12 @@ case class Oauth2TokenResponse( securityLevel: Long, userId: String, tokenType: String -) +) extends OAuth2TokenResponse.Basic -object Oauth2TokenResponse { +object ExtendedOAuth2TokenResponse { import com.ocadotechnology.sttp.oauth2.circe._ - implicit val decoder: Decoder[Oauth2TokenResponse] = + implicit val decoder: Decoder[ExtendedOAuth2TokenResponse] = Decoder.forProduct11( "access_token", "refresh_token", @@ -34,6 +88,6 @@ object Oauth2TokenResponse { "security_level", "user_id", "token_type" - )(Oauth2TokenResponse.apply) + )(ExtendedOAuth2TokenResponse.apply) } diff --git a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrantProvider.scala b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrantProvider.scala index 6f78ff09..478d0090 100644 --- a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrantProvider.scala +++ b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/PasswordGrantProvider.scala @@ -9,7 +9,7 @@ import sttp.model.Uri import cats.syntax.all._ trait PasswordGrantProvider[F[_]] { - def requestToken(user: User, scope: Scope): F[Oauth2TokenResponse] + def requestToken(user: User, scope: Scope): F[ExtendedOAuth2TokenResponse] } object PasswordGrantProvider { diff --git a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/RefreshTokenResponse.scala b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/RefreshTokenResponse.scala index 0562d4f7..53b8716b 100644 --- a/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/RefreshTokenResponse.scala +++ b/oauth2/src/main/scala/com/ocadotechnology/sttp/oauth2/RefreshTokenResponse.scala @@ -19,7 +19,7 @@ private[oauth2] final case class RefreshTokenResponse( ) { def toOauth2Token(oldRefreshToken: String) = - Oauth2TokenResponse( + ExtendedOAuth2TokenResponse( accessToken, refreshToken.getOrElse(oldRefreshToken), expiresIn, diff --git a/oauth2/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeSpec.scala b/oauth2/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeSpec.scala index 4d4f51f3..774fd3de 100644 --- a/oauth2/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeSpec.scala +++ b/oauth2/src/test/scala/com/ocadotechnology/sttp/oauth2/AuthorizationCodeSpec.scala @@ -5,6 +5,10 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import sttp.model.Uri import AuthorizationCodeProvider.Config._ +import sttp.client3.testing._ +import scala.util.Try +import sttp.monad.TryMonad + class AuthorizationCodeSpec extends AnyWordSpec with Matchers { @@ -119,5 +123,93 @@ class AuthorizationCodeSpec extends AnyWordSpec with Matchers { } + "authCodeToToken" should { + val tokenUri = baseUri.withPath("token") + val redirectUri = Uri.unsafeParse("https://app.example.com/post-logout") + val authCode = "auth-code-content" + val clientSecret = Secret("secret") + + "decode valid extended response" in { + implicit val testingBackend = SttpBackendStub(TryMonad) + .whenRequestMatches(_ => true) + .thenRespond(""" + { + "access_token": "123", + "refresh_token": "456", + "expires_in": 36000, + "user_name": "testuser", + "domain": "somedomain", + "user_details": { + "username": "", + "name": "", + "forename": "", + "surname": "", + "mail": "", + "cn": "", + "sn": "" + }, + "roles": [], + "scope": "", + "security_level": 0, + "user_id": "", + "token_type": "" + } + """) + val response = AuthorizationCode.authCodeToToken[Try, ExtendedOAuth2TokenResponse]( + tokenUri, + redirectUri, + clientId, + clientSecret, + authCode + ) + response.isSuccess shouldBe true + } + + "decode valid basic response" in { + implicit val testingBackend = SttpBackendStub(TryMonad) + .whenRequestMatches(_ => true) + .thenRespond(""" + {"access_token":"gho_16C7e42F292c6912E7710c838347Ae178B4a", "scope":"repo,gist", "token_type":"bearer"} + """) + val response = AuthorizationCode.authCodeToToken[Try, OAuth2TokenResponse]( + tokenUri, + redirectUri, + clientId, + clientSecret, + authCode + ) + response.isSuccess shouldBe true + } + + "fail effect with circe error on decode error" in { + implicit val testingBackend = SttpBackendStub(TryMonad) + .whenRequestMatches(_ => true) + .thenRespond("{}") + val response = AuthorizationCode.authCodeToToken[Try, OAuth2TokenResponse]( + tokenUri, + redirectUri, + clientId, + clientSecret, + authCode + ) + response.toEither shouldBe a[Left[io.circe.DecodingFailure, _]] + } + + "fail effect with runtime error on all other errors" in { + implicit val testingBackend = SttpBackendStub(TryMonad) + .whenRequestMatches(_ => true) + .thenRespondServerError() + val response = AuthorizationCode.authCodeToToken[Try, OAuth2TokenResponse]( + tokenUri, + redirectUri, + clientId, + clientSecret, + authCode + ) + response.toEither shouldBe a[Left[RuntimeException, _]] + } + + } + } diff --git a/oauth2/src/test/scala/com/ocadotechnology/sttp/oauth2/TokenSerializationSpec.scala b/oauth2/src/test/scala/com/ocadotechnology/sttp/oauth2/TokenSerializationSpec.scala index 2277dee1..504fb448 100644 --- a/oauth2/src/test/scala/com/ocadotechnology/sttp/oauth2/TokenSerializationSpec.scala +++ b/oauth2/src/test/scala/com/ocadotechnology/sttp/oauth2/TokenSerializationSpec.scala @@ -51,8 +51,8 @@ class TokenSerializationSpec extends AnyWordSpec with Matchers { "token_type": $tokenType }""" - jsonToken.as[Oauth2TokenResponse] shouldBe Right( - Oauth2TokenResponse( + jsonToken.as[ExtendedOAuth2TokenResponse] shouldBe Right( + ExtendedOAuth2TokenResponse( accessToken, refreshToken, expiresIn.seconds,