はじめに
Bluesky
のデスクトップアプリ for Macを Compose for Desktop
でちまちまと作っている。
通信は Ktor
+ Ktorfit
というKMPのデファクトなライブラリを使っている。
Bluesky
のライムライン取得や投稿などはHTTPリクエストを送ることになるが、それら通信の仕様やBlueskyが実装している分散型SNSの仕様は AT Protocol
として定義されている。
通信の流れとしてはよくある感じで、
createSession
でセッションを生成する- 以降はここで得られたトークンをHeaderに入れて通信する(
Bearer Authentication
) - セッションの有効期限が切れたら
refreshSession
でリフレッシュ
となる。
Ktor
では各リクエストにトークンを渡す設定は簡単に行える。
install(Auth) { bearer { loadTokens { val accessToken = dataSource.getAccessToken() val refreshToken = dataSource.getRefreshToken() BearerTokens(accessToken, refreshToken) } } }
こんな実装をしておくとヘッダーにAuthorization: Bearer ${your_access_token}
が付与される。
また、リフレッシュトークンの仕組みも備わっており、こちらもかなり簡単に実装ができるようになっている。
install(Auth) {
bearer {
refreshTokens {
// トークンをrefreshするようなエンドポイントを叩くなりして新しいトークンを得る
BearerTokens(newAccessToken, newRefresshToken)
}
}
}
Ktorのリフレッシュトークン機構の発動条件
このリフレッシュトークンの仕組みが発動するのは、
に限られる。
多くのサービスでは問題ないと思うが、トークン有効期限切れのときに401を返さないサービスも存在する。
Bluesky
もそうだった。
Bluesky
の場合、有効期限が切れているときには次のようなエラーレスポンスが返ってくる。
400 Bad Request {"error":"ExpiredToken","message":"Token has expired"}
これだとKtor
のリフレッシュトークンの仕組みは発動しないため、代替案を考える必要があった。
案1 発動条件を変更する(没)
Ktor
は柔軟に設計されている印象を受けていたので、リフレッシュトークンに関してもその発動条件を変更することができるだろうと思った。
ドキュメントと実装をみたが、どうやらできそうになかった。
できても良いような気がしているので、もしできるなら方法を知りたい......。
案2 エラー情報を改変して返す(没)
カスタムプラグイン(後述)を使って「status=400かつerror=ExpiredTokenの場合には401エラーが返ってきた」ことにするよう、偽装できないかと考えた。
結果、こちらも無理そうだった。カスタムプラグインでは返ってきたレスポンスの内容を編集するような操作はできなかった。
案3 カスタムプラグインで実装する(採用)
Ktor
にはカスタムプラグインという仕様が存在する。OkHttp
のInterceptor
のように、リクエストやレスポンス(またはその両方)の前後に任意の処理を挟むことができる。
- 通信がstatusCode=400で、かつエラーレスポンスの
error
がExpiredToken
だった場合、 refreshSession
を実行し、- 新たに取得したトークンで400エラーとなった最初の通信をリトライする
というような実装をする。
例としてはこんな感じ。
class RefreshTokenPluginFactory { fun create() = createClientPlugin("RefreshTokenPlugin") { on(Send) { request -> // 目的のリクエストを実行 val originalCall = proceed(request) // 400エラーで失敗し if (originalCall.response.status == HttpStatusCode.BadRequest) { val errorResponse = originalCall.response.body<ErrorResponse>() // エラーが`ExpiredToken`だったとき if (errorResponse.error == "ExpiredToken") { // refreshSessionをリクエスト val newSession = originalCall.client.request { method = HttpMethod.Post // see https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/server/refreshSession.json url("https://bsky.social/xrpc/com.atproto.server.refreshSession") // ここはrefreshTokenを使う // see https://atproto.com/specs/xrpc#authentication header("Authorization", "Bearer ${refreshToken}") }.body<Session> // 新しく取得したトークンをもとのリクエストヘッダーに追加 request.headers["Authorization"] = "Bearer ${newSession.accessToken}" // 再度リクエスト proceed(request) } else { originalCall } } else { originalCall } } } }
これをinstall
すれば良い。
install(refreshTokenPluginFactory.create())
ただ、このままだと色々と問題がある。
RefreshSessionがループしないようにする
上記のサンプル実装ではPOST /refreshSession
をリクエストする場合にもプラグイン処理が呼ばれる。
そのため、POST /refreshSession
がExpiredToken
エラーになるとPOST /refreshSession
をリクエストして...と無限ループに陥る可能性がある。
従ってガードが必要。
if (request.url.toString().contains("refreshSession")) { return@on process(request) }
RefreshTokenが利用されない
Bluesky
の場合(というか他のサービスでもそうかも知れないが)、POST /refreshSession
を行う場合にはrefreshToken
を利用する必要がある。
しかし最初に例示したloadTokens{}
を使っている場合、上記のようにプラグイン内でrefreshToken
を設定していたとしても、accessToken
で上書きされてしまう。
トークン設定はプラグインの中で行う実装をし、loadTokens
は使わないことでこの問題を回避できる。
if (request.url.toString().contains("refreshSession")) { return@on process(request) } request.headers["Authorization"] = accessToken val originalCall = process(request)
これは削除する。
install(Auth) { bearer { loadTokens { val accessToken = dataSource.getAccessToken() val refreshToken = dataSource.getRefreshToken() BearerTokens(accessToken, refreshToken) } } }
全体的に実装はもうちょっと整理したほうがいい気がする。
例えば「ヘッダーに情報を詰める処理」と「リフレッシュトークンの処理」はそれぞれ別のプラグインとして実装するのもアリかもしれない。
そもそも、もっと良い方法がありそうな気もするのだけれど。
以上。