はじめに
所属しているプロダクトで、auth0を使っているので、JWTの勉強も兼ねて実装を行います。
今回は、公式が出している記事(Build and Secure APIs with Scala and the Play Framework)を参考に実装します!
公式の記事ではOption
とTry
のエラーハンドリングが混在しているため、今回はEitherに統一してエラーハンドリングしてみます。
(公式の記事が執筆されたのは2018年で、サッカーロシアW杯があった年です。懐かしいですね)
※環境
Scala 2.13.14
Play Framework 3.0.5
Java 11
実装方針
こちら(JWT認証の流れを理解する)の記事の、公開鍵認証の場合の流れを愚直に実装します。
- リクエストのHTTPヘッダーからjwtトークンを取得
- auth0に公開鍵をリクエスト
- 公開鍵でjwtトークンを検証
実装で出てくる見慣れない単語について
jwk
公開鍵や秘密鍵をJSON形式で表現したものです。
Auth0などのIDプロバイダが、この形式を使って公開鍵を提供し、JWTの署名を検証する際に使われます。
claims(クレーム)
jwtのペイロード部分に入っているjwtトークンに関する情報です。
カスタムクレームを定義すれば認可情報とかも入れられます。
ペイロードにはクレームが含まれます。 登録されたクレームのセットがあります。たとえば、iss (発行者)、exp (有効期限)、sub (主題)、およびaud(観客)。 これらのクレームは必須ではありませんが、有用で相互運用可能なクレームのセットを提供するために推奨されます。 ペイロードには、従業員の役割などのカスタムクレームを定義する追加の属性を含めることもできます。
https://www.ibm.com/docs/ja/cics-ts/6.x?topic=cics-json-web-token-jwt
実装
ライブラリ情報
公式の記事に記載してあるものと異なっていますが、jwt-scalaのライブラリの管理方法が変わったらしいです。
https://mvnrepository.com/artifact/com.pauldijou
// build.sbt
libraryDependencies ++= Seq(
"com.github.jwt-scala" %% "jwt-play-json" % "10.0.1",
"com.github.jwt-scala" %% "jwt-core" % "10.0.1",
"com.auth0" % "jwks-rsa" % "0.20.0",
)
エラー情報を保存
エラー情報を持つクラスです。
今回は認証で扱う処理をEither[AuthError,成功型]
でエラーハンドリングします。
case class AuthError(
errorMessage: String
)
Action
invokeBlock
private val jwtLogger: Logger = Logger(this.getClass)
override def invokeBlock[A](request: Request[A], block: Request[A] => Future[Result]): Future[Result] = {
(for {
bearerToken <- extractBearerToken(request)
claim <- authService.validateJwt(bearerToken)
} yield claim) match {
case Right(_) => block(request)
case Left(authError) =>
jwtLogger.error(authError.errorMessage)
Future.successful(Unauthorized)
}
}
httpヘッダーからBearerトークンを取得する
private val headerTokenRegex = """Bearer (.+?)""".r
private def extractBearerToken[A](request: Request[A]): Either[AuthError, String] =
(request.headers.get(HeaderNames.AUTHORIZATION).collect {
case headerTokenRegex(token) => token
}) match {
case Some(token) => Right(token)
case None => Left(AuthError("Authorization header missing or invalid. Expected format: Bearer <token>"))
}
AuthService
サービス、テナント情報
公式の記事では環境変数からサービスの情報を取り込んでいるのですがここでは割愛します。
private val domain = "×××.auth0.com"
private val audience = "https://××××.example.com" // https://scala-api.example.com
private val issuer = s"https://$domain/"
private val jwtRegex = """(.+?)\.(.+?)\.(.+?)""".r
認証処理
def validateJwt(token: String): Either[AuthError, JwtClaim] = for {
jwk <- getJwk(token)
claims <- JwtJson.decode(token, jwk.getPublicKey, Seq(JwtAlgorithm.RS256)) match {
case Success(claims) => Right(claims)
case Failure(error) => Left(AuthError(error.getMessage))
}
_ <- validateClaims(claims)
} yield claims
jwk取得
private def getJwk(token: String): Either[AuthError, Jwk] = {
for {
spToken <- splitToken(token)
header <- decodeElements(spToken)
jwtHeader = JwtJson.parseHeader(header)
jwkProvider = new UrlJwkProvider(s"https://$domain")
kid <- jwtHeader.keyId.toRight(AuthError("Unable to retrieve kid"))
jwk <- Try(jwkProvider.get(kid)) match {
case Success(jwk) => Right(jwk)
case Failure(exception) => Left(AuthError(exception.getMessage))
}
} yield jwk
}
// jwtトークンからheader部分を抽出
private def splitToken(jwt: String) = jwt match {
case jwtRegex(header, _, _) => Right(header)
case _ => Left(AuthError("Token does not match the correct pattern"))
}
// header部分をデコード
private def decodeElements(header: String): Either[AuthError,String] = {
Try(JwtBase64.decodeString(header)) match {
case Success(header) => Right(header)
case Failure(_) => Left(AuthError(s"Invalid header: $header"))
}
}
jwtが改ざんされていないか検証
implicit val clock: Clock = Clock.systemUTC() // Clockを明示的に指定
private def validateClaims(claims: JwtClaim) = {
if (claims.isValid(issuer, audience)) {
Right(claims)
} else {
Left(AuthError("Invalid JWT claims: issuer or audience mismatch"))
}
}
コントローラー
def post() = authAction(parse.json) async { request =>
...処理
}
まとめ
JWTの実装を通じて、公開鍵を使った検証の流れを学ぶことができました!
また、Eitherを使うことによって、シンプルなエラーハンドリングが実現できたと思います。
ライブラリエラー対応
公式の記事に従って実装したところ、ライブラリ自体が存在しないというエラーが発生しました。
(mvnrepositoryの更新情報を調べて解決することができました。)
記事の公開から時間が経っていたため、エラーが出た際には詰んでしまったかと思いましたが、諦めずに調べた結果、解決できて良かったです。