📑

Play Framework(scala)×Auth0でJWT認証を実装する

2024/11/12に公開

はじめに

現在、所属しているプロダクトの認証機能において Play Framework と Auth0 を利用しています。
今回は、認証機能の理解を深めるため、Play Framework で JWT 認証を実装する方法について紹介します。

Auth0公式が公開している記事を参考にしています。

JWT認証とは

JWT(JSON Web Token)認証は、API認証に広く利用されている方式のひとつです。
署名機能によってデータの改ざんを検知することが可能です。
また、トークンに認証情報を含んでいるため、サーバー側でのデータ参照が不要なステートレス認証が実現できます。

JWTは、Json Web Token の略称で認証の中でもAPIの認証機能によく使用されるのがJWT認証です。JWT の特徴として、独自の署名機能によりデータの改ざん検知が可能であることがあげられます。またトークン単体でデータを含むため、サーバーでのデータ参照が不要なステートレスな認証が実現できます。これらの利点から、Web API等での認証・認可によく利用されるようになっています。

引用元: https://www.ibm.com/docs/ja/cics-ts/6.x?topic=cics-json-web-token-jwt

JWTの構成

JWTは、3つの部分で構成されたトークンです。これらはドット(.)で区切られています。

  • Header: アルゴリズム情報などが含まれます。
  • Payload: JWTに関する情報(クレーム)が入っています。たとえば、トークンの有効期限(exp)や発行者(iss)などが定義されます。
  • Signature: トークンの改ざんを検知するための署名です。これにより、受信者はトークンが発行元によって改ざんされていないことを確認できます。
// 例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

実装で重要な用語

JWK(JSON Web Key)

公開鍵を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

実装方針

こちらの記事の、「公開鍵認証の場合」の流れに沿って実装します!

  1. リクエストのHTTPヘッダーからJWTトークンを取得
  2. Auth0に公開鍵をリクエスト
  3. 公開鍵でJWTトークンを検証

実装

※環境

Scala 2.13.14
Play Framework 3.0.5
Java 11

ライブラリ情報

公式の記事とは一部異なりますが、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

それでは、JWT 認証を行う Action を作成します。この Action では、以下を行います。

  1. リクエストヘッダーから JWT を取得する
  2. JWT の署名・クレームを検証する

invokeBlock

// AuthAction.scala

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トークンを取得する

Auth0 のアクセストークンは Bearer トークンとして保持されています。
extractBearerToken は、リクエストヘッダーにあるトークン情報を取得します。

// AuthAction.scala

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

authService.validateJwt において、JWT の署名・クレームを検証しています。
取得した JWT の署名・クレームを検証し、JWT からクレームを取得する AuthService クラスを作成します。

認証設定

公式の記事では環境変数から認証設定を取り込んでいるのですがここでは省略します。

// AuthService.scala

  private val domain = "×××.auth0.com"

  private val audience = "https://××××.example.com"

  private val issuer = s"https://$domain/"

認証処理(大枠)

JWT の署名を検証する処理は、以下のように書くことができます。
Auth0 側では RSA で署名しているものとしています。
検証用の公開鍵は Auth0 が公開しており、getJwk メソッドで取得しています。最後に、validateClaims において JWT の issuer と audience を検証し、発行者が正しいかどうかを確認しています。

// AuthService.scala

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取得

getJwkメソッドは以下のような手順で検証用の公開鍵を取得します。

  1. splitToken メソッドでトークンをヘッダー・ペイロード・署名に分割し、ヘッダー部分を取得する
  2. decodeElements メソッドでヘッダー部分をデコードし、JWTのヘッダー情報を取得する
  3. keyId (kid) をもとにJWKを取得する
// AuthService.scala

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
}

private val jwtRegex = """(.+?)\.(.+?)\.(.+?)""".r

// トークンを分割してheader部分を取得
private def splitToken(jwt: String) = jwt match {
  case jwtRegex(header, _, _) => Right(header)
  case _                      => Left(AuthError("Token does not match the correct pattern"))
}

// header部分をBase64でデコード
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"))
  }
}

発行者が正しいかどうかを確認

validateClaimsメソッドは、JWTのクレームに含まれる発行者(issuer)と対象者(audience)が、認証設定で定義した値と一致しているかを確認します。

// AuthService.scala

private def validateClaims(claims: JwtClaim) = {
  implicit  val clock: Clock = Clock.systemUTC() // Clockを指定
  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を使った認証の実装を通じて、Auth0が提供するJWKを使用した公開鍵によるトークン検証を学びました。
今回の実装では、公式記事をベースにしつつ、エラーハンドリングをEitherに統一することで、エラーが発生した箇所や理由をより明確に把握できるようにしました。

また、ライブラリが存在しないというエラーに直面しましたが、Mavenリポジトリの更新情報を調べ、解決することができました。
記事の公開から時間が経っていたため、解決が難しいかと思いましたが、諦めずに調査を続けた結果、無事に解決できてよかったです。

今後は、クレームを活用した認可処理にも挑戦してみたいと思います!

nextbeat Tech Blog

Discussion