sttp-oauth2を試してみる

HTTPクライアントsttpの拡張として使えるoauth2クライアントsttp-oauth2を少し試してみました。
2022.06.28

はじめに

sttp-oauth2という便利そうなライブラリがあったので試してみました。

client credentialsフローでアクセストークンを取得する

client credentialsフローでアクセストークンを取得するにはAccessTokenProviderを使います。実態はClientCredentialsなのですが、前者はtraitになっているのでこちらを使うのが良さそうです。

object AccessTokenProvider {

  /** Create instance of AccessTokenProvider with sttp backend.
    *
    * `clientId`, `clientSecret` are parameters of your application.
    */
  def apply[F[_]](
    tokenUrl: Uri,
    clientId: NonEmptyString,
    clientSecret: Secret[String]
  )(
    backend: SttpBackend[F, Any]
  ): AccessTokenProvider[F] =
    new AccessTokenProvider[F] {
      implicit val F: MonadError[F] = backend.responseMonad

      override def requestToken(scope: Option[Scope]): F[ClientCredentialsToken.AccessTokenResponse] =
        ClientCredentials
          .requestToken(tokenUrl, clientId, clientSecret, scope)(backend)
          .map(_.leftMap(OAuth2Exception.apply).toTry)
          .flatMap(backend.responseMonad.fromTry)

    }

}

ここでコンストラクタの引数に以下を渡すのですが、単純なStringではないので少し面倒ですが型を合わせます。

  • トークンエンドポイント: sttp.model.Uri
  • クライアントID: eu.timepit.refined.types.NonEmptyString
  • クライアントシークレット: Secret[String]

Uri

sttp.model.Uriを生成するにはコンパニオンオブジェクトのファクトリまたはURI interpolatorを使います。検証をきっちりやる場合はEitherを返すファクトリがあり、後述のNonEmptyStringのファクトリとも型が合うのでこれを使うのがいいでしょう。

NonEmptyString

ご存じrefinedです。気持ちはわかるけど大げさでは?

Secret

アクセストークンやクレデンシャルはSecretでラップして扱うのがこのライブラリのポリシーのようです。以下のようにtoStringが工夫されていました。

val valueHashModulo: Int =
    value.hashCode % 8191 // 2^13 -1

override def toString: String =
  s"Secret($valueHashModulo)"

設定と適当なバックエンドを渡してrequestTokenするとトークンが取得できます。まとめると以下のようになります。

package example

import cats.*
import cats.effect.*
import cats.effect.IO.asyncForIO
import cats.effect.kernel.*
import cats.effect.std.*
import cats.implicits.*
import com.ocadotechnology.sttp.oauth2.*
import eu.timepit.refined.*
import eu.timepit.refined.auto.*
import eu.timepit.refined.types.string.*
import sttp.client3.SttpBackend
import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend
import sttp.client3.quick.*
import sttp.model.*
object Example extends IOApp.Simple:

  object config:
    def env(name: String): Option[String] = Option(System.getenv(name))
    final case class Config(
        tokenEndpoint: Uri,
        introspectionEndpoint: Uri,
        resourceEndpoint: Uri,
        clientId: NonEmptyString,
        clientSecret: Secret[String]
    )
    object Config:
      def apply(
          tokenEndpoint: String,
          introspectionEndpoint: String,
          resourceEndpoint: String,
          clientId: String,
          clientSecret: String
      ): Config =
        Config(
          uri"$tokenEndpoint",
          uri"$introspectionEndpoint",
          uri"$resourceEndpoint",
          NonEmptyString.unsafeFrom(clientId),
          Secret(clientSecret)
        )
    def apply: Option[Config] =
      Apply[Option].map5(
        env("auth_server_token_endpoint"),
        env("auth_server_introspection_endpoint"),
        env("resource_endpoint"),
        env("client_id"),
        env("client_secret")
      )(Config.apply)

  def run: IO[Unit] =
    IO.fromOption(config.apply)(new IllegalArgumentException).flatMap {
      config =>
        backend[IO].use { backend =>
          for {
            token <- accessTokenProvider(config)
              .apply(backend)
              .requestToken(None)
            _ <- Console[IO].println(token.toString)
          } yield ()
        }
    }
	//AccessTokenResponse(Secret(-2519),None,9698 seconds,Some(openid offline_access profile root email))

  def backend[F[_]: Async] =
    AsyncHttpClientCatsBackend
      .resource[F]()

  def accessTokenProvider[F[_]: Async](c: config.Config) =
    AccessTokenProvider[F](
      c.tokenEndpoint,
      c.clientId,
      c.clientSecret
    )

ClientCredentialsBackend

リクエストにアクセストークンを自動で付与してくれるsttpのバックエンドも提供されています。生成にするには先ほどと同様の設定かAccessTokenProviderを渡します。

object SttpOauth2ClientCredentialsBackend {

  def apply[F[_]: Monad, P](
    tokenUrl: Uri,
    clientId: NonEmptyString,
    clientSecret: Secret[String]
  )(
    scope: Option[Scope]
  )(
    backend: SttpBackend[F, P]
  ): SttpOauth2ClientCredentialsBackend[F, P] = {
    val accessTokenProvider = AccessTokenProvider[F](tokenUrl, clientId, clientSecret)(backend)
    SttpOauth2ClientCredentialsBackend(accessTokenProvider)(scope)(backend)
  }

  def apply[F[_]: Monad, P](
    accessTokenProvider: AccessTokenProvider[F]
  )(
    scope: Option[Scope]
  )(
    backend: SttpBackend[F, P]
  ) =
    new SttpOauth2ClientCredentialsBackend[F, P](backend, accessTokenProvider, scope)

そうするとAccessTokenProviderを使ってアクセストークンを取得、リクエストに付与してくれます。

final class SttpOauth2ClientCredentialsBackend[F[_]: Monad, P] private (
  delegate: SttpBackend[F, P],
  accessTokenProvider: AccessTokenProvider[F],
  scope: Option[common.Scope]
) extends DelegateSttpBackend(delegate) {

  override def send[T, R >: P with Effect[F]](request: Request[T, R]): F[Response[T]] = for {
    token    <- accessTokenProvider.requestToken(scope)
    response <- delegate.send(request.auth.bearer(token.accessToken.value))
  } yield response

}

sttpのバックエンドなのでbasicRequestで組み立てたリクエストを送信できます。

def clientCredentialsBackend[F[_]: Async, P](c: config.Config) =
    SttpOauth2ClientCredentialsBackend[F, P](
      c.tokenEndpoint,
      c.clientId,
      c.clientSecret
  )(None)

clientCredentialsBackend(config)
              .apply(backend)
              .send(
                basicRequest.get(config.resourceEndpoint)
              )

まとめ

他にもAzCやPassword Grant、トークンリフレッシュにも対応しているようです。ゼロから作ろうとするとJSONのデコードやレスポンスをマッピングするcase classを作ったりと手間が多い部分なのをsttpで使える形でまとめてくれているのは便利だなと思います。