この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
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で使える形でまとめてくれているのは便利だなと思います。