ScalaJS OAuth
認証ライブラリ
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ライブラリのオーバーヘッド
主要リンク
- Scala.js公式サイト
- sttp-oauth2 (Scala JVM OAuth参考)
- Better Auth
- OIDC Client TS
- OAuth.net - Scala Libraries
- ScalaJS 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"))
}
}
}
}