STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

ブランドアプリの ID 基盤移行に向けた Cookie 管理の仕組みの実装

はじめに

こんにちは。STORES ブランドアプリで Android エンジニアをしている Yuto Koguchi (@10llip0p) です。

STORES では現在 ID 基盤の統一に取り組んでおり、複数のプロダクトへ共通のアカウントでログインしてシームレスに利用できる体験を目指しています。

私が担当するブランドアプリにおいても今年からこの新しい ID 基盤への移行に取り組んできました。

STORES の ID 基盤とセキュリティ観点

STORES では Open ID Connect (OIDC) に準拠した ID 基盤を自前実装しており、様々なプロダクトやサービスに導入を進めています。

OIDC (もとい OAuth)では認可リクエストを送るクライアントと実際に認可するクライアントの同一性を保証するため、認証・認可のフローの中でstateというパラメーターを付与・検証することで CSRF を防ぐ仕組みが使われています。またstateの有効性を担保するために Session と紐づけてやりとりすることが推奨されています。

https://openid-foundation-japan.github.io/rfc6819.ja.html#anchor15

https://openid-foundation-japan.github.io/rfc6819.ja.html#link_state_uasession

ブランドアプリでの対応

OIDC に従ってブランドアプリでアクセストークンを取得するまでのざっくりとした流れは以下のようになります。(あくまでアプリから見た処理や通信の流れのみで OIDC の詳細なフローは省いています)

  • ① API サーバーに認可リクエストの URL を取得するリクエストを送る
  • ② 認可リクエストの URL を返す
  • Android Custom Tabs で認可リクエストの URL を開く
  • ④ 認証完了後 DeepLink でブランドアプリに認可レスポンスをコールバックする
  • ⑤ DeepLink から取得した認可コードを使ってアクセストークンをリクエストする
  • ⑥ アクセストークンを返す

この際に先述のセキュリティ観点を考慮すると、①と⑥のリクエスト・レスポンスで同一の Session が共有されている必要があります。

Android アプリの一般的な HTTP クライアントであるOkHttpはデフォルトで Cookie 管理の仕組みを持っていません。WebView や Custom Tabs からアクセスする場合は特に考慮は必要ないのですが、今回のようにアプリからの HTTP リクエスト間でセッションを共有する場合には Cookie 管理の仕組みを追加する必要があります。

OkHttpで Cookie を使うための実装(CookieJar)は公式からJavaNetCookieJarというライブラリが公開されており導入はとても簡単です。JavaNetCookieJarokhttp3:okhttp-urlconnectionにバンドルされているため、依存関係を追加した上でOKHttpClient.Builder()に下記のような実装を追加することで Cookie 管理の仕組みを構築できます。 

OKHttpClient.Builder().apply {
    val cookieManager = CookieManager()
    cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER)
    cookieJar(JavaNetCookieJar(cookieManager))
}

ちなみにokhttp3v5.0.0 以降ではJavaNetCookieJarは 独立したライブラリとして公開されるようです。 https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp-java-net-cookiejar

JavaNetCookieJarを使う実装の問題と解決策の調査

JavaNetCookieJarを使うことで Session を共有する仕組みは実装完了!めでたし!といきたかったのですが、動作確認をしているとアクセストークンをリクエストする際に Session が消失している挙動が高頻度で発生することがわかりました。

この問題はブランドアプリの構造に起因しているのか、あるいは他に要因があるのか根本的な原因はわかっていません。ただJavaNetCookieJarは Cookie をメモリ上で管理するため、Custom Tabs に遷移する際に Cookie の保持に失敗していたり、あるいは DeepLink でアプリに復帰する際に意図せず揮発してしまったり、などのことが起きているのではないかと疑いました。

そこで Cookie をSharedPreferencesに保存して永続化する戦略を検証してみることにしました。この方法に則った実装としてはPersistentCookieJarというライブラリが OSS で公開されており、JavaNetCookieJarと同様に簡単に仕組みを構築することができます。

https://github.com/franmontiel/PersistentCookieJar

そしてPersistentCookieJarを使用して再度動作確認を行ったところ、Session が消失する問題は再現しなくなったため Cookie の永続化が有効なことがわかりました。

これらのことからブランドアプリの ID 基盤移行にあたっては、 Cookie の永続化を考慮した実装を行うことにしました。

Cookie 永続化の方法としては上記のPersistentCookieJarを使った実装が最も手軽です。ただ PersistentCookieJarは2025年時点で最新の commit が8年前であり、また 100% Java で実装されていることなどから今からプロダクションに導入するのは少々気が引けます。

そこでSharedPreferencesを使った Cookie 永続化の仕組みを持つ CookieJar を自分で実装することにしました。これはPersistentCookieJarの実装を読んだ上でそれほど複雑な実装をしていなかったということや、現代においては AI の力を借りることで実装の難易度は高くないだろうということを考えての判断になります。

実装した CookieJarCustomCookieJarのソースコードをほぼ原文ママで紹介します。

class CustomCookieJar(  
    context: Context,  
) : CookieJar {  
    private val prefFacade = CookiePrefFacade(context)  
    private val cookieCache = mutableMapOf<String, List<Cookie>>()  
  
    override fun loadForRequest(url: HttpUrl): List<Cookie> {  
        synchronized(this) {  
            val cookies = cookieCache[url.host] ?: prefFacade.loadCookies(url).also {  
                if (it.isNotEmpty()) {  
                    cookieCache[url.host] = it  
                }  
            }  
  
            val validCookies = cookies.mapNotNull { cookie ->  
                cookie.takeIf { it.expiresAt > System.currentTimeMillis() }  
            }  
            if (cookies != validCookies) {  
                saveCookies(url, validCookies)  
            }  
  
            return validCookies  
        }  
    }  
  
    override fun saveFromResponse(  
        url: HttpUrl,  
        cookies: List<Cookie>,  
    ) {  
        saveCookies(url, cookies)  
    }  
  
    private fun saveCookies(  
        url: HttpUrl,  
        cookies: List<Cookie>,  
    ) {  
        synchronized(this) {  
            cookieCache[url.host] = cookies  
            prefFacade.saveCookies(url, cookies)  
        }  
    }  
  
    fun clearCache() {  
        cookieCache.clear()  
        prefFacade.clear()  
    }  
}

CookiePrefFacadeは SharedPreferences にアクセスするためのデータソースです。詳細な実装の説明は割愛しますが、保存用のsaveCookiesと読み出し用のloadCookiesの2つのインタフェースを介して、Key-Value の形式で URL (host) と Cookie の対を読み書きする実装となっています。 Cookie はメモリ上にもキャッシュしており、リクエスト時に検証して必要に応じてセットする仕組みとしています。

またclearCache()を実行することでログアウト等の任意のタイミングで Cookie をパージする機構を持たせています。すなわちCustomCookieJarはシングルトンなインスタンスとして利用することを想定した設計となります。

CustomCookieJarを使って動作確認をしたところ、PersistentCookieJarと同様に問題は再現せず現状は安定して動作しています。またJavaNetCookieJarPersistentCookieJarと比較して必要最小限のコンパクトな実装で済んだことから、メンテナンスの面でも特に懸念や不安がなく自作したメリットを感じています。

最後に

今回は STORES ブランドアプリの ID 基盤移行の取り組みにあたり、Cookie 永続化の仕組みの実装を紹介しました。

ID 基盤の統一によりますます便利につながっていく STORES のプロダクトにぜひご期待ください。