WebサイトからSet-Cookieを返したとき、WKWebViewはそれを永続化するのか（する）

お世話になります、 @sussan0416 です。
[Classi Advent Calendar 2020](https://qiita.com/advent-calendar/2020/classi) は13日目です。昨日は、 @kitaharamikiya さんによる [エンジニアが顧客へ提供したい価値を見つけるまでにやっていること](https://qiita.com/kitaharamikiya/items/80cee575de4389db310f) でした。

さて今日は、iOSのWebViewとCookieの話題です。

# サーバサイドのエンジニアに説明する気持ちで書きたい

iOS（iPadOS）のアプリでは、Webコンテンツを画面に表示するとき、`WKWebView`というViewコンポーネントを使用します。しかしこの`WKWebView`、iOSアプリエンジニアにとっては曲者というか、サーバサイドのエンジニアにとっても、**挙動がわかりにくい・デバッグが難しい**対象だなと感じています。

そこで今回は、**WKWebViewにおけるCookieの永続化に関する挙動**について、この1年で理解したことをまとめておきたいと思います。iOSの具体的な実装の話題は控えめにしつつ、iOSのWKWebViewの挙動をまとめたいと思います。

# WKWebViewとは...

`WKWebView`とは、iOS（iPadOS）アプリで、**Webコンテンツを表示する際に使用するViewオブジェクト**です。ブラウザでイメージするとわかりやすいでしょうか、下にイメージ画像をおいておきます。まぁとにかく、シンプルに、Webコンテンツを表示するだけの、ペラペラとしたスクリーンみたいなものです。

ブラウザだと……このあたり！
![スクリーンショット 2020-12-12 14.34.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/a57e8fe4-ad88-136b-e80b-534dee825706.png)

アプリケーションにはいろんな画面要素（UI）がありますが、WebViewは、もう本当にここだけ。

念のため、地味に重要なことなので書いておきますが、このように**画面上にWebViewが存在すれば、それはWebViewのインスタンスがあるということです。画面から消えると、当然ながらインスタンスは消えます**。
（あー……画面に見えていなくても、裏に隠れているとかであれば、インスタンスは残りますが……）

ちなみに、`WKWebView`の`WK`というプレフィックスは、iOS SDKの[WebKitフレームワーク](https://developer.apple.com/documentation/webkit)のクラスであることを示しています。

では、`WKWebView`の挙動を以下にまとめていきます。

# Webサイトから返すSet-Cookieは、永続化される（期限のあるものは）

Webサイトのレスポンスに、`Set-Cookie`ヘッダをつけることがあると思います。このCookieが永続化されているかというと……**ちゃんと永続化されています！**
このあたりは、普通のブラウザと一緒ですね。

|有効期限の有無|永続化|WKWebViewのインスタンスが新たに生成されたとき|
|:--|:--|:--|
|あり|永続化される|永続化してあるCookieが、リクエストにセットされる|
|なし|永続化されない（WKWebViewインスタンスが削除されると消える）|リクエストにセットされない[^processpool]|

このように、有効期限が設定されているCookieは、新たにWebViewの画面を開いたときにセットされた状態になります。

有効期限のないセッションオンリーのCookieは、WebViewが画面から消えることで（インスタンスが削除されることで）Cookieは消えてしまいます[^home_button]。

[^home_button]: ちなみに、アプリを一旦閉じる（ホームボタンを押してアプリをバックグラウンドに移動する）だけでは、このCookieは消えません。この場合はアプリが一時停止しているだけで、WebViewのインスタンスは残っているためです。アプリをKill（バックグラウンドのアプリ一覧からアプリを削除）した場合は、当然ながらWebViewインスタンスも削除されるので、有効期限が設定されていないCookieは消えます。

[^processpool]: `WKProcessPool`インスタンスをWKWebViewのインスタンス同士で共有している場合、`WKProcessPool`のインスタンスが消えるまではセッションCookieが維持される。`WKProcessPool`は、アプリが複数のWebViewを生成し、WebView同士でセッションを引き継ぎたい場合に使用する。

# 期限切れのCookieは、リクエストにセットされない

アプリ内に永続化される期限付きのCookieですが、当然ながら、**期限が切れたCookieはリクエストにセットされません**。なお、Cookieデータが削除されるタイミングは不明です。プログラムから明示的に削除することも可能です。

![スクリーンショット 2020-12-12 17.21.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/86841edd-8129-1182-5298-57d9bb9d9a26.png)

# Cookieは、アプリ内の`Library/Cookies`に永続化されている（らしい）

このあたりは…さほど詳しくないのですが、永続化されたCookieは、**アプリのサンドボックス内にある`Library/Cookies`に永続化される**そうです[^library]。サンドボックスというのは、アプリ固有のディレクトリで、他のアプリからは見ることのできない専用の領域のことです[^sandbox]。

[^sandbox]: （参照）[Apple Documentation Archive | File System Basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html)

[^library]: （参照） https://qiita.com/ShingoFukuyama/items/eede79a284c3669846e9#cookiecachecredential%E3%81%9D%E3%81%AE%E4%BB%96web%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AA%E3%81%A9%E3%82%92%E6%B6%88%E3%81%97%E3%81%9F%E3%81%84

ここで、iOSのファイル構成を、ちょっと覗いてみたいと思います[^file]。
[^file]: （参照）https://www.alphansotech.com/ios-application-files-and-folder-structure

参考にしたサイトの情報をまとめると、こんな感じのファイル構成になっているようです。

![スクリーンショット 2020-12-12 17.49.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/74955cd5-8aec-120c-e790-f2a7679baee6.png)

# `Library/Cookies`は安全だが完全ではない

`Library/Cookies`はCookieの保存場所として、あらゆるアプリが使用しています。この領域は、他のアプリからアクセスできない安全な領域です。ですが、完全なものではありません。……色々調べていくと、アプリのサンドボックス内のデータを抽出する方法もあるにはあるようですので。

また、Cookieの保存場所として、OS領域でありますKeychainを使用しても、これは同じことです。暗号化されるという意味ではより安全とは思いますが。

ちなみに、Libraryディレクトリは、MacやiCloudにバックアップされるようです（ユーザーの指定によるはず）。

# アプリを削除すると、`Library/Cookies`も削除される

Cookieは、アプリケーションのサンドボックス内に永続化されます。そのため、アプリを削除すると、一緒にCookieも端末から削除されます。
ただし、**CookieをOSのKeychain領域に保存するなどしていた場合は、アプリを削除してもKeychain領域にはCookieが残る**ことになります。ちなみに、Cookieの保存場所としてKeychainを指定することは、デフォルトではできません（おそらく普通はしない）。Keychainに保存する処理を別途実装することになります。

# WebViewのリクエストと、ネイティブ側(APIとの通信等)のリクエストは、クライアントが異なる

前提として、**WebViewとしての通信**（JavaScriptの通信を含む）と、**ネイティブアプリケーションとしての通信**（例: JSONでやり取りするAPIリクエスト）は、**互いに独立**しています。要するに、**クライアントが違う**ということです。それを示すように、WebViewの通信と、アプリとしてのAPI通信は、User-Agentが異なります。

|クライアント|User-Agent（例）|
|:--|:--|
|WKWebView|Mozilla/5.0 (iPhone; CPU iPhone OS 12_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)|
|CFNetwork（iOS SDKのフレームワーク）|MyApplication/1 CFNetwork/1128.0.1 Darwin/19.6.|

たとえばWebViewからのリクエストに対して`Set-Cookie`した場合、WebViewを使っている限りはCookieが適用されますが、ネイティブ側からのリクエストに対しては、Cookieが適用されないということになります（逆もしかり）。

**「『アプリ』に対してSet-Cookieしたんだから、すべてのリクエストにCookieがついているだろう」と思ってはいけないということです。** :sob: 

もしこれを読んでいる人で、**いや〜うちのアプリはWebViewでログインさせてCookie使うんだけど、それ以降はネイティブのリクエストもあるのよ……**なんていうプロジェクトの人がいたら、やっぱりCookieをWebViewとネイティブ間で同期させたくなりますよね。
**基本的には、同期されません。**クライアントを超えてCookieやらセッションやらを同期することはできません。じゃぁどうするか。どちらか一方のクライアントからCookieを抽出して、もう一方のクライアントにセットすることで対応しています。

# Set-CookieしたCookieがリクエストに適用されているか、見ることはできる?

- `Library/Cookie`が見えにくくされているように、保存されたCookieそのものを見るのは難しく手間がかかります。
- **プロキシアプリを使い通信内容をキャプチャする**ことで、Cookieがセットされているかを確認することができる場合があります（だいたい見える）。
- もし開発用ビルドのアプリを使って挙動確認をしているのであれば、端末を接続したMacの方で、Safariのインスペクタを開くとCookieをチェックすることができます（[方法](https://sussan-po.com/2019/10/20/ios-web-html-inspector/)）

# ちなみに、WKWebViewにはプライベートブラウズモードもある

あるよ程度に知っておくと良さそうです。DataStoreの挙動が変わったりするので注意です。
[プライベートブラウズの実装方法（iOSエンジニア向け）](https://sussan-po.com/2020/06/16/wkwebview-private-browse/)

# まとめ

- Webサイト側から見たときに気になりそうな、iOSのWebViewのCookieについてまとめました
- 基本的なCookie挙動はブラウザと変わらず、永続化もされます
- アプリがWebViewとネイティブ両方で通信する場合、双方でCookieが同期されるわけではないということを、ちょっとだけ意識しておくと良さそうです

以上、この１年でわかったWKWebViewのCookieに関する挙動まとめでした。

それでは失礼します。みなさん良きクリスマスをお迎えください:christmas_tree:
明日は、 @youichiro さんです！

---

# 付録（iOSアプリエンジニア向け）

テスト用にアプリを組んでいろいろ試しました。挙動をまとめます。

## 登場人物

- WKWebsiteDataStore.httpCookieStore（WebViewのCookie保存場所）
- HTTPCookieStorage（ネイティブ側のCookie保存場所）

|処理|結果|
|:--|:--|
|HTTPCookieStorageにCookieをセットする|WKWebViewには反映されない|
|websiteDataStore.httpCookieStoreに、Cookieをセットする|条件付きで、HTTPCookieStorageに同期（後述）|
|WKWebView使用中に、WebサイトからSet-CookieされたCookie|HTTPCookieStorageには同期されない|
|WKUserScriptを使用して、JavaScript（document.cookie）でCookieをセット|HTTPCookieStoreには同期されないが、有効期限のあるものはwebsiteDataStore.httpCookieStoreに永続化されている|

## WKWebView→HTTPCookieStoreにCookieが同期される条件

**`WKWebView`をインスタンス化するときに渡す`WKWebViewConfiguration`に、あらかじめCookieをセットしておく**と、WKWebViewがインスタンス化されるときに、HTTPCookieStorageにも同期されているようだった。
**インスタンス化されたあとは、同期されない（Set-Cookie無念）**。

```swift:
lazy var webView: WKWebView! = {
    let persistentCookie = HTTPCookie(properties: [.name   : "cookie_1_min",
                                                   .domain : "example.com",
                                                   .path   : "/",
                                                   .value  : "1",
                                                   .expires: Date(timeIntervalSinceNow: 60),
                                                   .secure: true])!
    // setCookieする
    let config = WKWebViewConfiguration()
    config.websiteDataStore
        .httpCookieStore
        .setCookie(persistentCookie, completionHandler: nil)

    // ここでconfigを渡すことで、Cookieが同期されるっぽい
    let view = WKWebView(frame: view.bounds, configuration: config)
    // レイアウトの処理は端折る

    // 順序が入れ替わると、同期されない!!!
    view.configuration
        .websiteDataStore
        .httpCookieStore
        .setCookie(HTTPCookie(......), completionHandler: nil)

    return view
}()

override func viewDidLoad() {
    super.viewDidLoad()

    view.addSubview(webView)
    let url = URL(string: "https://〜〜")!
    let request = URLRequest(url: url)
    webView.load(request)
}
```

## WKUserScriptでセットしたCookieは、WebViewとしては永続化されている

そもそもドキュメントへのインジェクションなので、最初のリクエストにはセットされないのが残念ではある。
インジェクトしたスクリプトが実行されたあとは、有効期限のあるものについては永続化されている。
HTTPCookieStorageには同期されない。

```swift
lazy var webView: WKWebView! = {
    let script = "document.cookie = 'cookie_from_script=1; domain=example.com; path=/; expires=Sun, 13-December-2020 00:00:00 GMT'"

    let userScript = WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false)

    let userContent = WKUserContentController()
    userContent.addUserScript(userScript)

    let config = WKWebViewConfiguration()
    config.userContentController = userContent

    // ここでconfigを渡すけれど、HTTPCookieStorageにはCookie同期されない
    let view = WKWebView(frame: view.bounds, configuration: config)
    // レイアウトの処理は端折る

    return view
}()

override func viewDidLoad() {
    super.viewDidLoad()
    view.addSubview(webView)

    let url = URL(string: "https://〜〜")!
    let request = URLRequest(url: url)
    webView.load(request)
}
```

そもそもUserScriptって、拡張機能とかの用途だよねきっと。

以上！