ScalaJS OAuth

認証ライブラリScalaJSOAuthクライアントサイドJavaScriptScala

認証ライブラリ

ScalaJS OAuth

概要

ScalaJS OAuthは、Scala.jsアプリケーションでOAuth 2.0認証を実装するためのクライアントサイド認証ライブラリです。TypeScriptベースのモダンなOAuthクライアントとの統合やJavaScriptライブラリの活用により、堅牢なOAuth認証フローを提供します。

詳細

ScalaJS OAuthは、Scala.jsエコシステムにおけるOAuth 2.0認証の実装を支援するライブラリです。直接的なScala.js専用OAuthライブラリは限定的ですが、JavaScriptの豊富なOAuthライブラリエコシステムとScala.jsのJavaScriptインターオペラビリティを活用することで、実用的なOAuth認証システムを構築できます。特に、TypeScriptで実装されたBetter AuthやOIDC Client TSなどのモダンなOAuth 2.0ライブラリとの統合、JavaScript faรงadeパターンを使った既存OAuthライブラリの活用、Scala.jsのネイティブHTTPクライアントを使った独自実装などのアプローチが可能です。sttp-oauth2のようなScala JVMライブラリの知見を活用しつつ、フロントエンドに特化した認証フローを実装できます。

メリット・デメリット

メリット

  • Scala統合: Scala.jsによる型安全なフロントエンド開発
  • JavaScript互換: 豊富なJavaScript OAuthライブラリとの相互運用性
  • モダンOAuth: OAuth 2.1、PKCE、OpenID Connectなど最新標準対応
  • ファサードパターン: 既存JSライブラリの型安全なラッパー作成
  • フレームワーク統合: React、Vue、Angularなどとの統合が可能
  • 型安全性: Scalaの強力な型システムによる安全性向上
  • 関数型プログラミング: Scalaの関数型パラダイムを活用

デメリット

  • 専用ライブラリ不足: ScalaJS専用OAuthライブラリの選択肢が限定的
  • JS依存: JavaScriptライブラリへの依存による複雑性
  • 学習コスト: ScalaJSとOAuth両方の知識が必要
  • コミュニティ規模: JavaScriptと比較して小さなコミュニティ
  • 設定の複雑さ: ビルド設定とJSライブラリ統合の複雑性
  • デバッグ難易度: クロスコンパイル環境でのデバッグ困難
  • パフォーマンス: JavaScriptライブラリのオーバーヘッド

主要リンク

書き方の例

JavaScript OAuthライブラリのファサード

// JavaScript OAuth ライブラリのファサード定義
import scala.scalajs.js
import scala.scalajs.js.annotation._

@js.native
@JSImport("@auth/client", JSImport.Default)
object AuthClient extends js.Object {
  def createAuthClient(config: AuthConfig): AuthClientInstance = js.native
}

@js.native
trait AuthConfig extends js.Object {
  val baseURL: String = js.native
  val credentials: js.Object = js.native
}

@js.native
trait AuthClientInstance extends js.Object {
  def signIn(): js.Promise[AuthResponse] = js.native
  def signOut(): js.Promise[Unit] = js.native
  def getUser(): js.Promise[js.UndefOr[User]] = js.native
}

// Scala.jsでの使用
object OAuth {
  def createClient(baseURL: String): AuthClientInstance = {
    val config = js.Dynamic.literal(
      baseURL = baseURL,
      credentials = js.Dynamic.literal(
        clientId = "your-client-id",
        redirectUri = "http://localhost:3000/callback"
      )
    ).asInstanceOf[AuthConfig]
    
    AuthClient.createAuthClient(config)
  }
}

PKCE フロー実装

// PKCE (Proof Key for Code Exchange) 実装
import scala.scalajs.js
import scala.util.Random
import java.util.Base64
import java.security.MessageDigest

class PKCEOAuthClient(
  clientId: String,
  authEndpoint: String,
  tokenEndpoint: String,
  redirectUri: String
) {
  
  private var codeVerifier: Option[String] = None
  
  def generateCodeVerifier(): String = {
    val bytes = new Array[Byte](32)
    Random.nextBytes(bytes)
    val verifier = Base64.getUrlEncoder.withoutPadding()
      .encodeToString(bytes)
    codeVerifier = Some(verifier)
    verifier
  }
  
  def generateCodeChallenge(verifier: String): String = {
    val digest = MessageDigest.getInstance("SHA-256")
    val hash = digest.digest(verifier.getBytes("UTF-8"))
    Base64.getUrlEncoder.withoutPadding().encodeToString(hash)
  }
  
  def getAuthorizationUrl(scope: String = "openid email profile"): String = {
    val verifier = generateCodeVerifier()
    val challenge = generateCodeChallenge(verifier)
    val state = generateState()
    
    val params = Map(
      "response_type" -> "code",
      "client_id" -> clientId,
      "redirect_uri" -> redirectUri,
      "scope" -> scope,
      "state" -> state,
      "code_challenge" -> challenge,
      "code_challenge_method" -> "S256"
    )
    
    s"$authEndpoint?${params.map { case (k, v) => s"$k=${js.URIUtils.encodeURIComponent(v)}" }.mkString("&")}"
  }
  
  private def generateState(): String = {
    Random.alphanumeric.take(16).mkString
  }
}

OAuth認証フロー管理

import scala.concurrent.Future
import scala.scalajs.js
import org.scalajs.dom
import org.scalajs.dom.ext.Ajax

// OAuth認証状態管理
case class AuthState(
  isAuthenticated: Boolean,
  user: Option[User],
  accessToken: Option[String],
  refreshToken: Option[String]
)

case class User(
  id: String,
  email: String,
  name: String
)

class OAuthManager(client: PKCEOAuthClient) {
  private var currentState = AuthState(false, None, None, None)
  private val stateKey = "oauth-state"
  
  def initiateLogin(): Unit = {
    val authUrl = client.getAuthorizationUrl()
    dom.window.location.href = authUrl
  }
  
  def handleCallback(code: String, state: String): Future[AuthState] = {
    exchangeCodeForToken(code).map { tokenResponse =>
      val newState = AuthState(
        isAuthenticated = true,
        user = Some(parseUserFromToken(tokenResponse.idToken)),
        accessToken = Some(tokenResponse.accessToken),
        refreshToken = tokenResponse.refreshToken
      )
      
      persistState(newState)
      currentState = newState
      newState
    }
  }
  
  private def exchangeCodeForToken(code: String): Future[TokenResponse] = {
    val data = js.Dynamic.literal(
      grant_type = "authorization_code",
      client_id = client.clientId,
      code = code,
      redirect_uri = client.redirectUri,
      code_verifier = client.codeVerifier.getOrElse("")
    )
    
    Ajax.post(
      url = client.tokenEndpoint,
      data = js.JSON.stringify(data),
      headers = Map("Content-Type" -> "application/json")
    ).map { xhr =>
      val response = js.JSON.parse(xhr.responseText)
      TokenResponse(
        accessToken = response.access_token.asInstanceOf[String],
        refreshToken = Option(response.refresh_token.asInstanceOf[String]),
        idToken = response.id_token.asInstanceOf[String]
      )
    }
  }
  
  private def parseUserFromToken(idToken: String): User = {
    // JWT デコードとユーザー情報抽出
    val payload = decodeJWT(idToken)
    User(
      id = payload.sub,
      email = payload.email,
      name = payload.name
    )
  }
  
  def logout(): Unit = {
    currentState = AuthState(false, None, None, None)
    clearPersistedState()
  }
  
  private def persistState(state: AuthState): Unit = {
    dom.window.localStorage.setItem(stateKey, js.JSON.stringify(state))
  }
  
  private def clearPersistedState(): Unit = {
    dom.window.localStorage.removeItem(stateKey)
  }
}

case class TokenResponse(
  accessToken: String,
  refreshToken: Option[String],
  idToken: String
)

React統合での使用

// ScalaJS React での OAuth 統合
import slinky.core._
import slinky.core.annotations.react
import slinky.web.html._

@react class LoginComponent extends Component {
  type Props = Unit
  
  case class State(
    isLoading: Boolean = false,
    user: Option[User] = None,
    error: Option[String] = None
  )
  
  private val oauthManager = new OAuthManager(
    new PKCEOAuthClient(
      clientId = "your-client-id",
      authEndpoint = "https://auth.example.com/oauth/authorize",
      tokenEndpoint = "https://auth.example.com/oauth/token",
      redirectUri = dom.window.location.origin + "/callback"
    )
  )
  
  def initialState: State = State()
  
  def render(): ReactElement = {
    div(
      state.user match {
        case Some(user) =>
          div(
            p(s"Welcome, ${user.name}!"),
            p(s"Email: ${user.email}"),
            button(
              onClick := (_ => oauthManager.logout()),
              "Logout"
            )
          )
        case None =>
          div(
            h2("Please Login"),
            button(
              onClick := (_ => handleLogin()),
              disabled := state.isLoading,
              if (state.isLoading) "Logging in..." else "Login with OAuth"
            ),
            state.error.map(err => p(style := js.Dynamic.literal(color = "red"), err))
          )
      }
    )
  }
  
  private def handleLogin(): Unit = {
    setState(_.copy(isLoading = true, error = None))
    oauthManager.initiateLogin()
  }
}

セキュアなAPI呼び出し

// 認証付きAPI呼び出し
import scala.concurrent.Future
import org.scalajs.dom.ext.Ajax

class AuthenticatedApiClient(oauthManager: OAuthManager) {
  
  def makeAuthenticatedRequest[T](
    url: String,
    method: String = "GET",
    data: Option[String] = None
  )(decoder: String => T): Future[T] = {
    
    oauthManager.currentState.accessToken match {
      case Some(token) =>
        val headers = Map(
          "Authorization" -> s"Bearer $token",
          "Content-Type" -> "application/json"
        )
        
        val request = method.toUpperCase match {
          case "GET" => Ajax.get(url, headers = headers)
          case "POST" => Ajax.post(url, data.getOrElse(""), headers = headers)
          case "PUT" => Ajax.put(url, data.getOrElse(""), headers = headers)
          case "DELETE" => Ajax.delete(url, headers = headers)
          case _ => throw new IllegalArgumentException(s"Unsupported method: $method")
        }
        
        request.map(xhr => decoder(xhr.responseText))
          .recover {
            case _: Exception if xhr.status == 401 =>
              // トークンが無効な場合の処理
              oauthManager.logout()
              throw new Exception("Authentication required")
          }
        
      case None =>
        Future.failed(new Exception("Not authenticated"))
    }
  }
  
  // 使用例
  def getUserProfile(): Future[UserProfile] = {
    makeAuthenticatedRequest("/api/user/profile") { response =>
      val json = js.JSON.parse(response)
      UserProfile(
        id = json.id.asInstanceOf[String],
        name = json.name.asInstanceOf[String],
        email = json.email.asInstanceOf[String],
        avatar = Option(json.avatar.asInstanceOf[String])
      )
    }
  }
}

case class UserProfile(
  id: String,
  name: String,
  email: String,
  avatar: Option[String]
)

トークンリフレッシュ機能

// 自動トークンリフレッシュ
class TokenManager(
  refreshEndpoint: String,
  clientId: String
) {
  
  def refreshAccessToken(refreshToken: String): Future[TokenResponse] = {
    val data = js.Dynamic.literal(
      grant_type = "refresh_token",
      client_id = clientId,
      refresh_token = refreshToken
    )
    
    Ajax.post(
      url = refreshEndpoint,
      data = js.JSON.stringify(data),
      headers = Map("Content-Type" -> "application/json")
    ).map { xhr =>
      val response = js.JSON.parse(xhr.responseText)
      TokenResponse(
        accessToken = response.access_token.asInstanceOf[String],
        refreshToken = Option(response.refresh_token.asInstanceOf[String]),
        idToken = response.id_token.asInstanceOf[String]
      )
    }
  }
  
  // 自動リフレッシュ機能付きAPIクライアント
  def withAutoRefresh[T](
    apiCall: String => Future[T]
  )(accessToken: String, refreshToken: Option[String]): Future[T] = {
    
    apiCall(accessToken).recoverWith {
      case _: Exception => // 401 Unauthorized の場合
        refreshToken match {
          case Some(rt) =>
            refreshAccessToken(rt).flatMap { newTokens =>
              apiCall(newTokens.accessToken)
            }
          case None =>
            Future.failed(new Exception("No refresh token available"))
        }
    }
  }
}