sttp-oauth2を試してみる
はじめに
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で使える形でまとめてくれているのは便利だなと思います。