スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

GraphQL Trusted Documents の実装パターンを Signed Query に移行しました (Android編)

こんにちは、Androidエンジニアの@morux2です。スタディサプリ小学・中学講座ではデータ通信にGraphQLを採用しており、開発者が信頼したクエリのみを処理する仕組みとしてTrusted Documentsを実装しています。この度、従来のPersisted QueryからSigned Queryという新しいTrusted Documentsの実装パターンに移行したので、Android側の実装についてご紹介できればと思います。

Signed Query の詳細な説明は以下のブログをご覧ください。 blog.studysapuri.jp

前提

我々はGraphQLクライアントとしてApollo Kotlinを採用しています。Apollo KotlinはGraphQLクエリからKotlinのモデルを生成するGraphQLクライアントです。現時点で使用しているバージョンはv3.8.3です。

自動生成されるモデルのイメージ

query Sample {
   grade {
       code
   }
}
public class SampleQuery() : Query<SampleQuery.Data> {
   // (省略)

   @ApolloAdaptableWith(SampleQuery_ResponseAdapter.Data::class)
   public data class Data(
      public val grade: Grade,
   ) : Query.Data
}

クエリを叩くサンプルコード

fun getSample(): Flow<ApolloResponse<SampleQuery.Data>> {
   val apolloClient = ApolloClient.Builder()
      .serverUrl("https://your.graphql.endpoint/")
      // 必要な設定をここに追加
      .build()
   return apolloClient.query(SampleQuery()).toFlow()
}

Apollo Kotlin(旧Apollo Android) については、こちらのブログも参照ください。 blog.studysapuri.jp

移行の背景

従来のPersisted Queryの仕組み

Persisted Query は、あらかじめアプリケーションで利用するクエリをGraphQLサーバーに登録し、登録されたクエリのみリクエストを受け付ける仕組みです。Apollo-Kotlinでは、enableAutoPersistedQueriesをtrueに設定することで、クライアントからのリクエストにPersisted Query Hash(クエリのハッシュ値)を付与することができます。*1

Persisted Queryのアーキテクチャ

Apollo Clientのサンプルコード

val apolloClient = ApolloClient.Builder()
   .serverUrl("https://your.graphql.endpoint/")
   .enableAutoPersistedQueries(true)
   .build()

GraphQL Over HTTPでは、リクエストパラメータにextensionsという任意の拡張を行うためのフィールドが用意されており、enableAutoPersistedQueriesをtrueにすると extensionsにPersisted Query Hashがセットされます。*2

リクエストのイメージ

{
  "operationName": "Sample",
  "variables": {},
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "xxxxxxxxxxxxxxxxxxxxx"
    }
  }
}

Persisted Queryの課題

Persisted Queryの課題は、クライアントが実行するクエリをあらかじめサーバーに登録する必要があることです。普段バックエンドの開発を担当しないアプリエンジニアにとって、GraphQLサーバーのデプロイは心理的ハードルが高いものでした。サーバーのデプロイが不要になれば、アプリのリリースがより手軽になり、作業時間の短縮も期待できます。

Signed Queryを用いたデータ通信

そこで考案されたのがSigned Queryです。Signed Queryはサーバーのデプロイが不要なクエリの検証の仕組みです。挙動は次のようになります。

  • クライアントは共通鍵を用いてそれぞれのクエリに対する署名を作成し、リクエスト時にクエリと署名を合わせて送信する
  • サーバーサイドでもリクエストされたクエリに対して共通鍵を用いて署名を作成し、リクエストに付与された署名と一致するか確認する

Signed Query のアーキテクチャ

リクエストのイメージ

{
  "operationName": "Sample",
  "variables": {},
  "query": "query Sample { grade { code } }",
  "extensions": {
    "signedQuery": {
      "signature": "yyyyyyyyyyyyyyyyyyyyy"
    }
  }
}

実装方針

ここからはSigned Queryの実装を紹介していきます。Persisted Queryの仕組みを参考にしつつ、自前で生成した署名をクエリに付与します。なお、URLパラメータに載せるにはクエリが長すぎるため、Signed Queryでは一律POSTリクエストを用います。

  • ビルド時

    • アプリから叩くクエリの一覧を取得する
    • 共通鍵を用いてそれぞれのクエリの署名を作成し、静的なjsonファイルに保存する
  • ランタイム

    • それぞれのクエリに対応する署名をjsonファイルから読み取り、POSTリクエストのextensionsに付与する

実装詳細

1. アプリから叩くクエリの一覧を取得する (ビルド)

apollo-gradle-pluginでgenerateOperationOutputをtrueに設定すると、クエリの情報を含むoperationOutput.jsonというファイルがビルド時に各モジュールに生成されます。operationOutput.jsonは、モジュールごとのgenerateApolloSourcesというGradleタスクの中で生成されます。詳細はApollo Kotlinの実装をご確認ください。

生成されるoperationOutput.jsonのイメージ

{
 "xxxxxxxxxxxxxxxxxxxxx": { // query Sample { grade { code } } のハッシュ値
   "name": "Sample",
   "source": "query Sample { grade { code } }",
   "type": "query"
 }
}

我々のプロジェクトはマルチモジュール構成なので、次のようなGradleのConvention pluginsを定義し、各モジュールに適用しています。*3

Apollo用のGradle Convention pluginsのイメージ

plugins {
   id 'com.apollographql.apollo3'
}

dependencies {
   implementation libs.apollo.runtime
   implementation projects.schema
   apolloMetadata(projects.schema)
}

apollo {
   generateOperationOutput = true
}

2. 共通鍵を用いてそれぞれのクエリの署名を作成し、静的なjsonファイルに保存する (ビルド)

署名作成のタスクをビルド時に実行します。このタスクは先述のgenerateApolloSourcesのタスクに依存することで、operationOutput.jsonの生成が完了してから署名を作成することを担保します。署名の生成はgoのスクリプトで記載されており、クエリのHMACを計算しています。

署名生成のGradleタスク

task generateSignedQuery(type: Exec) {
   doFirst {
      // ここでgoのスクリプトを実行する
      // operationOutput.jsonを読み取り、それぞれのクエリの署名を作成し、静的なjsonファイルに保存する
   }
}

// generateSignedQueryに関するタスク依存関係の設定
allprojects { project ->
   project.afterEvaluate {
       // 全モジュールのgenerateApolloSources完了後にgenerateSignedQueryを実行する
       if (project.tasks.findByName('generateApolloSources') != null) {
           generateSignedQuery.dependsOn project.tasks.findByName('generateApolloSources')
       }

       // 全モジュールのpreBuild前にgenerateSignedQueryを実行する
       if (project.tasks.findByName('preBuild') != null) {
          project.tasks.findByName('preBuild').dependsOn generateSignedQuery
       }
   }
}

生成される署名はクエリのハッシュ値をkeyとし、署名情報をvalueに持ちます。なお、本番環境とステージング環境で異なる署名を利用しています。

署名情報が保存されたjsonファイルのイメージ

{
  "xxxxxxxxxxxxxxxxxxxxx": { // query Sample { grade { code } } のハッシュ値
    "production": "yyyyyyyyyyyyyyyyyyyyy", // query Sample { grade { code } } のHMAC
    "staging": "zzzzzzzzzzzzzzzzzzzzz"
  }
}

3. クエリに署名を付与する (ランタイム)

GraphQLリクエストをHTTPリクエストに変換するタイミングで、extensionsを挿入するような独自のHttpRequestComposerを作成します。HttpRequestComposerの実装はApolloDefaultHttpRequestComposerを参考にしています。

val serverUrl = "https://your.graphql.endpoint/"

val apolloClient = ApolloClient.Builder()
   .serverUrl(serverUrl)
   .enableAutoPersistedQueries(false)
   .networkTransport(
       HttpNetworkTransport.Builder()
           .httpRequestComposer(SignedQueryComposer(serverUrl))
           .build()
   )
   .build()

class SignedQueryComposer(
   private val serverUrl: String,
) : HttpRequestComposer {

   override fun <D : Operation.Data> compose(apolloRequest: ApolloRequest<D>): HttpRequest {
       val operation = apolloRequest.operation
       val customScalarAdapters = apolloRequest.executionContext[CustomScalarAdapters] ?: CustomScalarAdapters.Empty
       val sendDocument = apolloRequest.sendDocument ?: true
       val query = if (sendDocument) operation.document() else null
       return HttpRequest.Builder(
           // Signed Queryを用いる場合は、全てPOSTリクエストとなる
           method = HttpMethod.Post,
           url = serverUrl,
       ).body(
           buildPostBody(operation, customScalarAdapters, query) {
               name("extensions")
               writeObject {
                   // extensionsに渡すフィールドは事前にサーバーサイドと相談して決定しました
                   name("signedQuery")
                   writeObject {
                       name("signature")
                       // 静的なファイルから署名を読み込んでここに挿入します
                       // operation.id()でクエリのハッシュ値を取得可能なので、ハッシュ値をkeyにして署名情報を受け取ることができます
                       value("yyyyyyyyyyyyyyyyyyyyy")
                   }
               }
           }
       ).build()
   }
}

Apollo Kotlinが、独自のextensionsを挿入しやすくするFeature Requestを採用してくださったので、シンプルな実装にすることができました。 github.com

さいごに

この度、従来のPersisted QueryからSigned Queryに移行しました。移行はFirebase Remote Configでフラグ管理して、大きな問題なく完了しました。チャレンジングな課題でしたが、Apollo Kotlinの方々が親切かつスピーディーに対応してくださったこともあり、シンプルな実装に留めることができました。ありがとうございました。Signed Queryに移行したことによって、今までよりもより安全かつ高速なリリースを実現できそうです。ぜひ参考になれば嬉しいです。

*1:Automatic Persisted Queries(APQ)とPersisted Queryは別物であり、APQはクエリ信頼の仕組みを含みません。

*2:APQを用いると、クエリのハッシュ値だけでリクエストが可能です。

*3:参考 : https://www.apollographql.com/docs/kotlin/advanced/multi-modules/