0
0

playframework(scala)×Auth0でjwt認証を実装する

Last updated at Posted at 2024-09-28

はじめに

所属しているプロダクトで、auth0を使っているので、JWTの勉強も兼ねて実装を行います。
今回は、公式が出している記事(Build and Secure APIs with Scala and the Play Framework)を参考に実装します!
公式の記事ではOptionTryのエラーハンドリングが混在しているため、今回はEitherに統一してエラーハンドリングしてみます。
(公式の記事が執筆されたのは2018年で、サッカーロシアW杯があった年です。懐かしいですね)

※環境

Scala 2.13.14
Play Framework 3.0.5
Java 11

実装方針

こちら(JWT認証の流れを理解する)の記事の、公開鍵認証の場合の流れを愚直に実装します。

  1. リクエストのHTTPヘッダーからjwtトークンを取得
  2. auth0に公開鍵をリクエスト
  3. 公開鍵で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の更新情報を調べて解決することができました。)
記事の公開から時間が経っていたため、エラーが出た際には詰んでしまったかと思いましたが、諦めずに調べた結果、解決できて良かったです。
スクリーンショット 2024-09-28 18.35.42.png

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0