AppleとLINEのネイティブ認証をつくる(サーバー編)
この記事は every Tech Blog Advent Calendar 2025 の 29 日目の記事です。
こんにちは!開発1部で食事管理アプリ ヘルシカ のサーバーサイドの開発をしている 赤川 です。約1ヶ月にわたって続いたアドベントカレンダーも最終日となりました。
本記事では、ヘルシカiOS で Apple と LINE のネイティブ認証を導入した経験をもとに、ネイティブ認証のサーバー側の実装と周辺知識、実装時の細かいポイントについてお話しします。
iOS側の実装については、昨日公開の AppleとLINEのネイティブ認証をつくる(iOS編) をご覧ください。
tech.every.tv
前提
ヘルシカではもともと Web アプリベースの認証方法が採用されており、アプリ内で WebView が開きそこでIDやパスワードを入力して サインアップ / サインイン を行います。以下、AppleやLINEのIDプロバイダーを外部IdP、ヘルシカの認証サーバーを単に認証サーバーと呼ぶことにします。認証サーバーではOpenID Connectに則った実装がされており、おおまかには以下のような流れです。
ログインボタンを押す
アプリ内のWebViewで外部IdP(AppleやLINE)の入力画面が開く
入力成功後、外部IdPから認証サーバーにコールバック
認証サーバーが外部IdPとToken Exchangeを行い、認証サーバーが外部IdPのトークンを受け取る
外部IdPのトークンの検証に成功した後、ヘルシカAPIサーバー用のトークンを発行し、安全にClientに渡す
アプリはほぼWebViewを開くだけで、IdPとメインでやり取りするのはサーバーであることがわかります。
上記の形だと、例えばヘルシカにWebアプリが増えた、という場合でも同じエンドポイントが使え、認証サーバーの追加実装なしに拡張が可能なのがメリットと言えます。しかし、パスワードなどの入力は面倒ですし、忘れがちです。そこで、ユーザーにより簡単にサインアップ・サインインをしてもらえるように、Appleなら顔認証などiOSネイティブな方法、LINEならLINEアプリが開いてワンタップで認証できる方法を新しく採用することになりました。
Appleの認証画面
LINEの認証画面
アプリ側のAppleとLINEのネイティブ認証実装
(サーバー編)とタイトルに入れましたが、サーバー側の設計をするためにはまずアプリ側でネイティブ認証を実装する方法を知らなければなりません。AppleではASAuthorizationAppleIDProviderなどのクラス、LINEではLINEログインSDKというものが用意されています。
developer.apple.com
developers.line.biz
元のフローを思い出してみると、外部IdPのトークンを受け取るのは認証サーバーでした。しかし、上記を使用した場合、IDトークンを受け取るのはアプリになります。それぞれについて、少し深掘りしてみましょう。
Apple
LINE
LINEでは、 LoginManager を使います。LoginManager.shared.login でリクエストを送信しますが、Appleの時と違い nonce と state はSDK内で自動的に生成、検証されます。ただし、 nonce は独自に指定することも可能です。
成功すると、LoginResult を受け取ります。この中にアクセストークンやIDトークンなどが含まれています。
比較すると、以下の2つが共通していることがわかりました。
リクエストには独自の nonce を設定することができる。
成功すると、アプリがIDトークンなどを受け取ることができる。
IDトークンと nonce
サーバー側の設計に進む前に、IDトークンと nonce について復習しておきます。ご存知の方は無視して次のパートに進んでいただいて問題ありません。
まず、IDトークンは次のような形をしています。
eyJraWQiOiIxZTlnZGs3IiwiYWxnIjoiUlMyNTYifQ.
ewogImlzcyI6ICJodHRwczovL3NlcnZlci5leGFtcGxlLmNvbSIsCiAic3ViIjogIjI0ODI4OTc2MTAwMSIsCiAiYXVkIjogInM2QmhkUmtxdDMiLAogIm5vbmNlIjogIm4tMFM2X1d6QTJNaiIsCiAiZXhwIjogMTMxMTI4MTk3MCwKICJpYXQiOiAxMzExMjgwOTcwLAogIm5hbWUiOiAiSmFuZSBEb2UiLAogImdpdmVuX25hbWUiOiAiSmFuZSIsCiAiZmFtaWx5X25hbWUiOiAiRG9lIiwKICJnZW5kZXIiOiAiZmVtYWxlIiwKICJiaXJ0aGRhdGUiOiAiMDAwMC0xMC0zMSIsCiAiZW1haWwiOiAiamFuZWRvZUBleGFtcGxlLmNvbSIsCiAicGljdHVyZSI6ICJodHRwOi8vZXhhbXBsZS5jb20vamFuZWRvZS9tZS5qcGciCn0.
NTibBYW_ZoNHGm4ZrWCqYA9oJaxr1AVrJCze6FEcac4t_EOQiJFbD2nVEPkUXPuMshKjjTn7ESLIFUnfHq8UKTGibIC8uqrBgQAcUQFMeWeg-PkLvDTHk43Dn4_aNrxhmWwMNQfkjqx3wd2Fvta9j8yG2Qn790Gwb5psGcmBhqMJUUnFrGpyxQDhFIzzodmPokM7tnUxBNj-JuES_4CE-BvZICH4jKLp0TMu-WQsVst0ss-vY2RPdU1MzL59mq_eKk8Rv9XhxIr3WteA2ZlrgVyT0cwH3hlCnRUsLfHtIEb8k1Y_WaqKUu3DaKPxqRi6u0rN7RO2uZYPzC454xe-mg
https://openid.net/specs/openid-connect-core-1_0.html#id_tokenExample
2つのドットがあり3つのパートに区切られていることがわかります。これらは前から順にヘッダー、ペイロード、署名と呼ばれていて、このような形式のトークンを署名付き JWT (JSON Web Token) と言います。それぞれについて詳しく見ていきましょう。
まずヘッダーを Base64url デコードすると、以下のようなJSONが得られます。
{ "kid ":"1e9gdk7 ","alg ":"RS256 "}
algはJWTの署名に使用されたアルゴリズムを表します。 kid は署名検証用の公開鍵の識別子で、公開鍵暗号方式の署名の場合に含まれます。type: "JWT" というフィールドが含まれている場合もあります。
次に、ペイロードを検証、デコードしてみると、だいたい以下のような JSON が得られます。
{
"iss ": "https://server.example.com ",
"sub ": "248289761001 ",
"aud ": "s6BhdRkqt3 ",
"nonce ": "n-0S6_WzA2Mj ",
"exp ": 1311281970 ,
"iat ": 1311280970 ,
"name ": "Jane Doe ",
"given_name ": "Jane ",
"family_name ": "Doe ",
"gender ": "female ",
"birthdate ": "0000-10-31 ",
"email ": "[email protected] ",
"picture ": "http://example.com/janedoe/me.jpg "
}
下の方はプロフィール的な情報なので無視して、上側のフィールドの説明を書き込むと以下のようになります。
{
"iss ": "トークンを発行したサーバーの識別子 ",
"sub ": "ユーザーの識別子 ",
"aud ": "トークンを受け取るアプリの識別子(クライアントIDなど) ",
"nonce ": "nonce(下で説明します) ",
"exp ": "トークンの有効期限(UNIXタイムスタンプ形式) ",
"iat ": "トークンの発行日時(UNIXタイムスタンプ形式) "
}
nonce フィールドがありますね! nonce があることで、どのような利点があるかを以下で説明します。
登場人物は以下です。
IdP: IDトークンを発行する
Relying Party: 認証のサービスを使う(アプリや、そのサーバー)
ユーザー: ログインしようとしている
攻撃者: IDトークンを盗んで、不正にログインしようとしている
まず、 nonce がない場合です。
nonceがない場合
IDトークンが正しく、有効期限内であれば、誰でも何回でもログインできてしまうことがわかります。このような攻撃を、リプレイ攻撃と呼びます。
次に nonce がある場合です。
nonceがある場合
ユーザーのログイン後、保存されていた nonce はすでに削除されているので、攻撃者が盗んだIDトークンは使えなくなっています。 nonce が "Number used once" の略である、ということが納得できるかと思います。
最後に署名です。署名は、ヘッダーとペイロードから、IdPだけが持っている秘密鍵を使って計算、付与されます。IDトークンを受け取ったクライアントは、alg に書いてある方法に従って署名を検証します。ここで検証に失敗すれば、何者かによってヘッダーまたはペイロードが書き換えられているということになります。
サーバー側の実装
それでは、サーバー側の実装について考えていきましょう。結果的に、エンドポイントは2つになりました。
IDトークンの検証は nonce を含めサーバー側で行います。 LINEログインSDK のように nonce をアプリ側で検証をすることも可能ですが、結局それではIDトークンを何度も使えてしまい、サーバーから見ると nonce を含めていないのと同じ状態になってしまいます。そもそも、クライアントから送られてきたものをサーバーがそのまま信じることはよろしくありません。
そうすると、必然的に nonce の生成もサーバー側が行うことになります。 LINE と Apple の比較で、リクエストには独自の nonce を設定することができる、ということを確認したので、サーバー側から nonce を渡すエンドポイントを作れば良いですね。また、 nonce を生成してキャッシュするのにキーが必要なので、その認証認可セッションの ID もランダムに生成して返却します。これが1つ目のエンドポイントになります(わかりやすさのため、一部フィールドを省略しています)。
リクエスト
{
app_id: "アプリの識別子 ",
service_id: "IdPの識別子(line or apple) ",
type : "signup or signin "
}
レスポンス
{
nonce : "nonce ",
session_id: "その認証認可セッションの識別子 ",
}
クライアント側はこの送られてきた nonce を使い、IdPからIDトークンを取得し、セッションIDとともにサーバーに送ります。これが2つ目のエンドポイントです。
リクエスト
{
app_id: "アプリの識別子 ",
session_id: "その認証認可セッションの識別子 ",
id_token: "IDトークン ",
authorization_code: "認可コード(アクセストークンなどの取得に使う、Appleのみ) "
}
サーバー側はセッション ID からキャッシュしていた nonce を取り出し、IDトークンの検証時に一致を確認します。検証に成功後、サーバーのDBにユーザーを作成、または更新し、アプリのIDトークン、アクセストークン、リフレッシュトークンを発行して返却します。アプリのトークンの発行部分については Amazon Cognito のカスタム認証を使っているのですが、ここでは触れないことにします。
レスポンス
{
access_token: "アプリのアクセストークン ",
id_token: "アプリのIDトークン ",
refresh_token: "アプリのリフレッシュトークン ",
expires_in: "トークンの有効期限 "
}
nonce を使うことで、安全にIDトークンをやり取りしてネイティブ認証を実現できました!最終的な流れは以下の通りです。
ネイティブ認証のフロー
LINE の公式ドキュメントでも、同様の方法が推奨されています。図も付いていてわかりやすいので、合わせてご覧ください。
developers.line.biz
Appleの細かいポイント
Bundle ID と Service ID
Bundle ID はアプリ固有の識別子、 Service ID はWebアプリなどがサインインなどのWebサービスを使う際に使用する識別子です。
元々の Web アプリベースの認証方法では Service ID が使われていましたが、ネイティブ認証では Bundle ID が使われます。
この使い分けが必要になるのは、トークンの Exchange の処理、つまり、クライアントから送られた authorization_code を使ってアクセストークンやリフレッシュトークンを取得する処理です。
取得のためには専用のエンドポイント を叩きますが、リクエストのパラメータの一つに client_secret というのがあります。詳しい作成方法については以下を参照ください。
developer.apple.com
client_secret は署名した JWT で、 sub フィールドを持ちます。ここに入るものが、 Web アプリベースの認証なら Service ID 、ネイティブ認証なら Bundle ID となります(上記リンク先には App ID と書いてありますが、Bundle ID でも機能します)。
LINEの細かいポイント
API
SDKは自動で nonce や state を生成、検証してくれたりとなかなかリッチでしたが、APIも充実しています。特に、IDトークン検証用のエンドポイントが用意されています。
developers.line.biz
ローカルでIDトークンを検証するのは少しだけ大変なので、これは有難いです。今回はAppleとIDトークンの検証処理を共通化させたかったので採用しませんでしたが、LINEのみ実装する場合には良い選択肢かと思います。
IDトークンの検証アルゴリズム
LINEでは、ネイティブアプリと Web アプリで署名アルゴリズムが異なります。公式ドキュメント で以下のように記されています。
ネイティブアプリやLINE SDK、LIFFアプリに対してはES256(ECDSA using P-256 and SHA-256)が、ウェブログインに対してはHS256(HMAC using SHA-256)が返されます。
ES256 は公開鍵暗号方式、HS256 は共通鍵暗号方式で、ES256の方が鍵管理のリスクが低いです。私も実装後に知ったのですが、一般に MPA (Multiple Page Application) の Web アプリなどはAPIサーバーとIdPが同一管理下にあることが多く、クライアントで署名検証をすることがないため共通鍵暗号方式でも十分、ネイティブアプリや SPA (Single Page Application) の Web アプリは IdP と API サーバーが分離していることが多く、場合によってはクライアント側で検証をすることもあるため公開鍵暗号方式が推奨されている、という背景があるようです。
最後に
既に動いているサービスで安全な認証認可を実現するために、いろいろな記事、動画を参考にさせていただきました。受け売りですが、認証認可や課金などの実装は、いろいろな方の集合知の上に成り立つ類のものだと考えています。この記事がまた、これから認証認可を実装する方の一助となれば幸いです。
それでは少し早いですが、皆様、今年も1年お世話になりました。
来年もどうぞよろしくお願いいたします。
参考資料