← トップページに戻る

Cloudflare Access環境のAPIにiOSアプリ(SwiftUI)から通信する方法

概要と起きた問題

自作の自動売買ボット(CQAT)などのバックエンドAPIを、Cloudflare Tunnel経由で外部公開し、セキュリティを高めるためにCloudflare Zero Trustで「メール認証(Email OTP)」を設定した。

これによりブラウザからのアクセスは守られるようになったが、iOSアプリ(SwiftUI)からの通信時にログイン画面のHTMLが返されるようになり、JSONのパースエラー(Parse Error)が発生してしまった。

解決策:Service Token(サービストークン)の利用

プログラム(アプリ)からの自動通信を許可するために、Cloudflareで「Service Token」を発行し、アプリからのHTTPリクエストのヘッダーにそのトークンを含めて裏側で自動認証させるように設定する。

手順1:CloudflareでService Tokenを発行する

  1. Cloudflare Zero Trust ダッシュボードを開く。
  2. 左メニューから Access > Service Auth へ進み、「Add a service token」(または Create a service token)をクリック。
  3. 用途がわかる名前(例: CQAT-App)と有効期限(例: 1年)を設定して「Generate」を押す。
  4. 画面に Client IDClient Secret が表示される。
⚠️ 注意: Client Secret はこの画面を閉じると二度と確認できないため、必ずコピーして安全な場所にメモしておくこと。

手順2:アプリケーションにポリシー(許可ルール)を紐付ける

作成した通行証(トークン)を、対象のアプリ(ドメイン)で使えるように設定する。

  1. 左メニューの Access > Applications を開き、対象のアプリの「Edit」をクリック。
  2. 上部の Policies タブを開き、「Add a policy」 をクリック。
  3. 以下の内容でポリシーを追加し、保存する。
    • Action: Service Auth
    • Rule: Include を選択 > Selectorを Service Token にする > Valueで先ほど作ったトークン名を選択。

手順3:iOSアプリ(SwiftUI)側のコード改修

URLを直接指定してリクエストしていた部分を URLRequest に書き換え、ヘッダーに取得したIDとSecretを追加する。

修正前(例):

URLSession.shared.dataTask(with: url) { data, _, error in ... }

修正後:

var request = URLRequest(url: url)
// 取得したトークン情報をヘッダーに追加
request.addValue("取得したClient_ID", forHTTPHeaderField: "CF-Access-Client-Id")
request.addValue("取得したClient_Secret", forHTTPHeaderField: "CF-Access-Client-Secret")

URLSession.shared.dataTask(with: request) { data, _, error in
    // 以降の処理
}

これでアプリからの通信がCloudflareの認証を自動でパスし、無事にJSONデータを取得できるようになった。

おまけ:仮想通貨の「資産詳細」タブの追加

メイン画面に加えて、保有している現物資産と現在の建玉(ポジション)の個別状況を確認できるタブを新規作成した。ContentView.swift に以下のビューを追加し、MainTabViewTabView 内に組み込む。

struct AssetsView: View {
    @ObservedObject var vm: CQATViewModel
    
    var body: some View {
        NavigationView {
            List {
                // 現物資産の表示
                if let assets = vm.data?.assets, !assets.isEmpty {
                    Section(header: Text("保有資産 (現物)").foregroundColor(.gray)) {
                        ForEach(assets) { asset in
                            HStack {
                                Text(asset.currency).font(.headline).bold()
                                Spacer()
                                VStack(alignment: .trailing, spacing: 4) {
                                    Text("数量: \(String(format: "%.4f", asset.amount))").font(.subheadline)
                                    Text("現在価格: ¥\(Int(asset.price))").font(.caption).foregroundColor(.gray)
                                    Text("評価額: ¥\(Int(asset.value))").font(.subheadline).bold().foregroundColor(.green)
                                }
                            }
                            .padding(.vertical, 4)
                        }
                    }
                }
                
                // 建玉(ポジション)の表示
                if let positions = vm.data?.positions, !positions.isEmpty {
                    Section(header: Text("現在の建玉 (ポジション)").foregroundColor(.gray)) {
                        ForEach(positions) { pos in
                            HStack {
                                Text(pos.pair).font(.headline).bold()
                                Spacer()
                                VStack(alignment: .trailing, spacing: 4) {
                                    Text("数量: \(String(format: "%.4f", pos.amount))").font(.subheadline)
                                    Text("取得価格: ¥\(Int(pos.price))").font(.caption).foregroundColor(.gray)
                                }
                            }
                            .padding(.vertical, 4)
                        }
                    }
                }
            }
            .navigationTitle("仮想通貨情報")
            .refreshable { vm.fetchData() } // 下に引っ張って更新
        }
    }
}