ネイティブアプリがバックエンドサービスを利用するにあたり、OAuth 2やそれをベースとしたOIDC (OpenID Connect) を利用して認証/認可が行われることはよくあります。しかしながら、ネイティブアプリ特有の注意事項やセキュリティ上考慮すべき事柄があります。ネイティブアプリがOAuthクライアントとして実装すべきことや注意事項をまとめた資料として RFC 8252 - OAuth 2.0 for Native Apps (BCP 212) が存在します。本稿は、RFC 8252に基づき、ネイティブアプリによるOAuth実装のベストプラクティスの説明とその実装サンプルを紹介します。
本稿の構成は、ネイティブアプリによるOAuthの流れおよび求められる要件やセキュリティ上の注意事項などベストプラクティスの説明部と、それを実装したサンプルコード部に分けられます。説明を飛ばしてサンプルコードを見たい方は、サンプルコード部 までスキップしてください。実装サンプルはSwiftUI, Swift Concurrencyに対応しています。
用語
-
OAuth
RFC 6749 - The OAuth 2.0 Authorization Frameworkおよびその関連資料で規定される認可プロトコルを指します。バージョンを明示せずに単に「OAuth」と言った場合、OAuth 2.0を指します。
-
ネイティブアプリ
iOS端末にインストールされるアプリケーションプログラムのことです。本稿では特にApp Store等を通じて不特定多数の人に提供することを想定した公開アプリケーションを指します (コンフィデンシャルな非公開アプリは本稿の対象外です)。単に「アプリ」といった場合もネイティブアプリと同じ意味で用いています。
-
組み込みユーザーエージェント
OAuth認可サーバとの認可処理を行うために、ネイティブアプリによってアプリ内にホストされるユーザーエージェントです。iOSの場合、WKWebViewによるWebViewベースのものが典型例です。組み込みユーザーエージェントを通じてユーザーが入力した情報は、ネイティブアプリから取得可能です。
-
外部ユーザーエージェント
OAuth認可サーバとの認可処理を行うために、ネイティブアプリから利用されるユーザーエージェントです。組み込みユーザーエージェントとの違いは、ネイティブアプリと外部ユーザーエージェントはそれぞれ独立したエンティティであり、外部ユーザーエージェントを通じてユーザーが入力した情報は、ネイティブアプリから取得できません。「外部」という表現が少し紛らわしいですが、iOSの場合、外部ユーザーエージェントをアプリ内に表示することも可能です。詳細な実装例は後述しますが、WebAuthenticationSession 等で表示するWeb UIが外部ユーザーエージェントに該当します。もちろん、ネイティブアプリの外部に起動したブラウザアプリ (Safari) も外部ユーザーエージェントにあたります。
ネイティブアプリを用いたOAuthの概要とポイント
以下は、ネイティブアプリによるOAuth通信の各登場人物の関係図(C4モデル)とシーケンス図です。C4モデル上の番号はシーケンス図の番号と対応しています。
関係図
シーケンス図
※以降の説明のために、C4モデル上の「認可エンドポイント」と「トークンエンドポイント」は「認可サーバ」に統合しています。
以下はネイティブアプリ向けOAuthにおける重要ポイントです。
組み込みユーザーエージェント (WebView) を使用しない (MUST NOT)
RFC 8252は、認可要求 (およびそれに伴うユーザー認証) に、組み込みユーザーエージェントを使用しないことを要求します (native apps MUST NOT use embedded user-agents to perform authorization requests [Section 8.12])。iOSアプリで言うと、WKWebView等WebViewベースの方法でユーザー認証画面を開いてはならないということです。
アプリは、組み込みユーザーエージェントを通じてユーザーが入力するIDやパスワード等の認証情報を取得することが可能です。これは、特にアプリが認可サービスにとってサードパーティである場合、外部のアクターが認証情報を盗むことができるということであり、セキュリティ上致命的な問題となります。仮に善意のファーストパーティアプリの場合であっても、知るべきでないユーザー認証情報にアクセス可能であることは最小権限の原則に反しており、攻撃対象領域 (Attack Surface) を拡大するリスクがあります。クライアント (アプリ) に認証情報を教えることなく権限を付与するOAuthの目的にも適していません。
組み込みユーザーエージェントの危険性を踏まえ、Google等サービス提供者によっては、WebViewを用いた認可要求を拒否しているケースもあります。
外部ユーザーエージェントを使用する (MUST)
これは、組み込みユーザーエージェントの禁止と対になるものですが、その代わりとして外部ユーザーエージェントの使用が要求されています (native apps MUST use an external user-agent to perform OAuth authorization requests.[Section 5])。
用語が少し紛らわしいですが、「外部」といってもアプリの外でブラウザアプリを別途立ち上げるということではありません。iOSの場合は、外部ユーザーエージェントをアプリ内に表示する機能として、WebAuthenticationSession や、ASWebAuthenticationSession、SFSafariViewController が標準で提供されています。それぞれの使い分けは、アプリがサポートするiOSバージョンや利用するUIフレームワーク、ユースケースに応じて使い分けることになります。本稿ではSwiftUI、Swift Concurrencyに対応した、最新のWebAuthenticationSessionを使った実装例を紹介します。WebAuthenticationSession自体はiOS 16.4以降で利用可能です。
PKCEを実装する (MUST)
ネイティブアプリを用いたOAuth通信において、アクセストークンを取得するまでの流れは、認可応答に含まれる認可コードとアクセストークンを交換する認可コードフローとなります。通常、認可応答はリダイレクトURIに基づいてユーザーエージェントから適切なアプリへリダイレクトされます。しかしながら、スマートフォン端末の場合、攻撃者がリダイレクトを悪用して不正なアプリに認可応答をリダイレクトし、認可コードが窃取されてしまうリスクが存在します (認可コード横取り攻撃)。
PKCE (Proof Key for Code Exchange) とは、認可コード横取り攻撃が発生した場合でも、不正アプリがアクセストークン取得できないようにする仕組みです。PKCEはRFC 7636で定義されています。ネイティブアプリはPKCEを実装しなければなりません (Public native app clients MUST implement the Proof Key for Code Exchange (PKCE [RFC7636]) extension to OAuth [Section 6])
PKCEの処理は以下の通り単純です。
- OAuthクライアントのアプリは、認可要求に先立って以下の
code_verifier
とcode_challenge
を生成する- code_verifier = 予測困難な暗号論的乱数
- code_challenge = BASE64URL(SHA256(code_verifier))
- code_challenge_method = S256 (固定リテラル)
- アプリは、認可要求のパラメータとしてcode_challenge, code_challenge_methodを含める
- 認可サーバは、払い出す認可コードに紐づけてcode_challenge, code_challenge_methodを保持する
- アプリは、トークン要求のパラメータとして、認可コードと、code_challenge生成元であるcode_verifierを含める
- 認可サーバは、トークン要求のcode_verifierを元にクライアントと同じ方法でcode_challengeを計算し、認可要求のcode_challengeと一致することを検証する。一致する場合のみ要求を受理する (不正アプリが認可コードを窃取できてもcode_verifierはわからないのでアクセストークンを取得できない)。
PKCEでは、code_challenge_methodとしてS256
以外にplain
という値も定義しています。plain
を使用する場合、code_verifierからSHA256ハッシュ値を生成することはせず、code_verifier = code_challengeとなります。しかしながら、S256
と比較して安全性の低いplain
の使用について、RFC 7636は、技術的な理由によりクライアントがSHA256をサポートできない場合にのみplain
の使用を許可しています (If the client is capable of using "S256", it MUST use "S256", (中略) Clients are permitted to use "plain" only if they cannot support "S256" for some technical reason)。iOS端末でSHA256を使用できない理由は無いので、本稿ではplain
を扱いません。
以下は上記のPKCE手順をシーケンス図として表したものです。認可サーバからの認可応答以降、正規のケースと、認可コードを窃取されたケースを分けて、流れを比較できるようにしています。
なお、PKCEを用いる場合は、OAuthのグラントフローは必然的に認可コードフローとなります。別のフローであるインプリシットフローについては、RFC 8252は非推奨としながらも禁止はしていません (the use of the Implicit Flow with native apps is NOT RECOMMENDED [Section 8.2])。しかし、インプリシットフローはセキュリティリスクが高く、またリフレッシュトークンが使えないので利便性も低く、さらには本稿執筆時点でドラフトである次期バージョンOAuth 2.1ではインプリシットフローを削除予定であることから、本稿ではインプリシットフローを扱いません。
アプリ固有のリダイレクトURI
認可サーバからの認可応答は、HTTPのリダイレクトと同じ仕組みでネイティブアプリへ転送されます。しかしながらWebアプリーションとは異なるネイティブアプリへのリダイレクトは、特殊な仕組みが必要になります。認可サーバからのHTTPレスポンスをネイティブアプリへリダイレクトする方法として、RFC 8252では以下の3種が取り上げられています (iOSの用語に置き換えています。括弧にRFC 8252での用語も併記しています)。
- ユニバーサルリンク (Claimed "https" Scheme URI) リダイレクト (SHOULD)
- カスタムURLスキーム (Private-Use URI Scheme) リダイレクト
- ループバックインターフェースリダイレクト
PKCEの箇所で説明したとおり、認可応答リダイレクトは認可コード横取り攻撃の対象となるポイントなので、リダイレクトに上記のどの方法を採用するかは重要になります。各方法の詳細は後述しますが、ユニバーサルリンクリダイレクトの採用を第一に検討してください。ユニバーサルリンクによる方法が、他の二つと比べて認可コード横取りリスクが最も低く、RFC 8252でも推奨されています (SHOULD)。
参考情報として、金融分野などの高度な安全性が求められるシステム向けキュリティプロファイルとして、OpenID Foundationが策定したFAPI (Financial-grade API Security Profile) 1.0 - Part 1: Baselineでは、カスタムURLスキームリダイレクト、およびループバックインターフェースリダイレクトを明示的に禁止しています。
リダイレクトURIに関して、アプリが複数の異なる認可サーバと通信する可能性がある場合、認可サーバMix-up攻撃対策として、以下の対応が要求されます (REQUIRED)。
- アプリは、認可サーバごとにユニークなリダイレクトURIを使用する
- アプリは、認可応答に含まれるリダイレクトURIが、認可要求で指定したリダイレクトURIと一致することを検証し、一致しない場合、認可応答を拒否する
ユニバーサルリンクリダイレクト
ユニバーサルリンク (RFC 8252では、Claimed "https" Scheme URIと呼ばれる) は、httpsスキームを持つ特定のURLを要求した場合に、特定のアプリを起動する仕組みです。例えばYouTubeへのリンクへアクセスしたときに、ブラウザでそのページを開くのではなくYouTubeアプリを立ち上げる、といった用途で用いられます。この仕組みを利用することで、リダイレクトURIとして、ユニバーサルリンクに設定した値を指定することで、アプリへ認可応答をリダイレクトすることが可能になります。ユニバーサルリンクは、AASAと呼ばれるどのURLでどのアプリを開くかという情報を記載したファイルを、対象のURLのドメインでホストする必要があり、アプリとURLドメインの信頼関係が確立されています。攻撃者がユニバーサルリンクを詐称することは難しく、他のカスタムURLスキーム等のリダイレクト方法と比較してよりセキュアとされています (とはいえ、ドメインやホストサーバ乗っ取り等リンクを奪われるリスクはゼロではないので、PKCEを用いて多重に防御する必要はあります)。
ユニバーサルリンク自体はOAuth以外でも使用されるものであり、サーバを含めたその設定方法については本稿の範囲外なのでAppleのドキュメント等を参照ください。
ユニバーサルリンクを用いたリダイレクトURIの例は以下のとおりです。形式的に通常のWebリソースを指すURLと区別はつきません。
https://app.example.com/oauth2redirect/example-provider
カスタムURLスキームリダイレクト
カスタムURLスキーム (RFC 8252ではPrivate-Use URI Schemeと呼ばれる)は、https
のような標準化されたスキームとは異なり、アプリが独自に定めた値を持つスキームです。特定のアプリとその独自スキームを紐づけるようOSに登録しておくことで、ブラウザ等を通じてそのスキームを持つURLが要求された場合に、HTTPリクエスを発行するのではなく登録されたアプリを起動させることができます。
OAuthの文脈で言えば、ネイティブアプリが外部ユーザーエージェントを通じて発行する認可要求のリダイレクトURIとしてカスタムURLスキームを持つ値を設定しておくことで、認可応答のリダイレクト先としてアプリを起動し、認可応答を外部ユーザーエージェントからアプリへ渡すことが可能となります。
カスタムURLスキームを持つリダイレクトURIは、アプリ間で意図せずスキーム衝突が発生しないように、以下に従ってください。
- スキームには、アプリ提供者が保持するドメイン名を逆順にしたものを用いる (MUST)
- 同一のアプリ提供者が複数アプリを提供する場合、アプリごとにユニークなスキームとする
以下は、カスタムURLスキームを用いたリダイレクトURIの例です。
com.example.app:/oauth2redirect/example-provider
スキームとして"myapp"のようなシンプルな値を使用するサンプルコードを見かけることもあるかもしれませんが、RFC 8252を考慮したコードではないと考えた方が良いでしょう。
カスタムURLスキームの使用にあたって最も留意しなければならない点は、誰でも好きなスキーム値を自由に登録できるということです。意図しないスキーム衝突を回避する方法は上記の通りですが、悪意のある攻撃者による同一スキーム使用を止めることはできません。端的な問題として、正規のアプリAと、攻撃者によるアプリBが同一スキームを用いていた場合、アプリAが発行した認可要求に対する認可応答リダイレクトをアプリBに奪われるリスクがあるということです。対策としてアプリがPKCEを実装しなければならないことは先述の通りです。
なお、本稿後半の実装サンプルで紹介する WebAuthenticationSession によるWeb認証セッションでは、セッションを開いたアプリに認可応答を返すので、同じスキームを使用する攻撃者が認可応答リダイレクトを直接的に横取りすることは困難です。
ループバックインターフェースリダイレクト
ループバックインターフェースを開くことができるアプリで用いることができます。典型的にはデスクトップアプリでの使用を想定した方法なので、本稿では簡単な紹介にとどめます。
以下がリダイレクトURIの例です。
http://127.0.0.1:51004/oauth2redirect/example-provider
http://[::1]:61023/oauth2redirect/example-provider
IPv4、IPv6どちらか片一方のみを前提とせずに、両方で待ち受けることが推奨されています (RECOMMENDED)。IPアドレス指定ではなく、localhost
を使用することは非推奨です (NOT RECOMMENDED)。localhost
という名前が意図せずループバックインターフェース以外を指している可能性があるからです。
なお、エフェメラルポートを用いる場合、リダイレクトURIのポート番号部が可変になるという特筆すべき特徴があります。これは、認可サーバ観点で特別な対応が必要になる (The authorization server MUST allow any port to be specified at the time of the request for loopback IP redirect URIs [Section 7.3]) ので、認可サーバの対応状況、動作仕様、設定について確認しておくことが重要です。
RFC 8252実装サンプルコード
いよいよ、ネイティブアプリ OAuth ベストプラクティスであるRFC 8252をiOS向けに実装したサンプルコードを説明します。本稿末尾にソースコード全体を掲載しています。
ここで紹介するサンプルコードは、RFC8252によるベストプラクティスの理解を深めることを第一の目的としており、意図的にサードパーティライブラリを使用せずに実装しています。現実のアプリケーション開発時は、AppAuthやご利用の認可サーバ/IdP提供のSDKなど信頼できるライブラリが利用可能であれば、積極的にご利用ください。
また、RFC 8252の対象範囲である認可要求からアクセストークンの取得に至るまでの通信部分に特化してカバーしています。OAuth通信と直接関係しないアプリケーション開発手法やデザインパターン等のベストプラクティスは本稿のスコープ外です。
以下のセクションに付与した番号は、シーケンス図の番号と対応しています。
認可要求 (1)
PKCE
まずは認可要求に必要なPKCE用データ生成を説明します。
import CryptoKit
import Foundation
enum CryptoError: Error {
case randomGenerationFailed
}
struct PKCE {
/// code_verifier
let verifier: String
/// code_challenge
let challenge: String
/// code_challenge_method
let method = "S256"
init() throws {
verifier = try Self.generateVerifier()
challenge = Self.computeChallenge(of: verifier)
}
/// PKCE用code_verifierを生成する
///
/// - Returns: 43バイトのcode_verifier
static func generateVerifier() throws -> String {
// 32バイト乱数をBASE64URLエンコードすると43バイトの文字列となる
return try randomBase64URLString(byteCount: 32)
}
/// PKCE用code_challengeを出力する
///
/// - Parameters:
/// - verifier: code_verifier
/// - Returns: verifierのSHA256ハッシュ値をBASE64URLエンコードした文字列
static func computeChallenge(of verifier: String) -> String {
let digest = SHA256.hash(data: Data(verifier.utf8))
return base64URL(of: Data(digest))
}
}
/// 指定バイト数の乱数を入力データとするBASE64URLエンコード文字列を生成する
///
/// - Parameters:
/// - byteCount: 入力データとなる乱数のバイト数
/// - Retunrs: BASE64URLエンコードした乱数文字列
func randomBase64URLString(byteCount: Int) throws -> String {
let random = try generateSecureRandomData(count: byteCount)
return base64URL(of: random)
}
/// 暗号学的 (cryptographically secure) 乱数を生成する
///
/// 乱数生成器として[SecRandomCopyBytes](https://developer.apple.com/documentation/security/secrandomcopybytes(_:_:_:)) を使用
///
/// - Parameters:
/// - count: 生成する乱数のバイト数
/// - Returns: 乱数
/// - Throws: `CryptoError.randomGenerationFailed` 乱数生成失敗時
func generateSecureRandomData(count: Int) throws -> Data {
var bytes = [UInt8](repeating: 0, count: count)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw CryptoError.randomGenerationFailed
}
return Data(bytes)
}
/// BASE64URLエンコードを行う
///
/// - Parameters:
/// - data: 入力データ
/// - Returns: BASE64URLエンコードした文字列
func base64URL(of data: Data) -> String {
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
行っていることはごく単純で、code_verifierとして暗号学的乱数 (cryptographically secure random)の文字列を生成し、code_challengeとして、code_verifierのSHA256ハッシュ値のBASE64URLエンコードした値を計算しているだけです。
一部補足します:
- 暗号学的乱数生成には、Apple提供の乱数生成APIである SecRandomCopyBytes を用いています。
SecRandomCopyBytes
は、AppAuthやAuth0 SDKでも採用されている実績のある方法です (ただしAuth0 SDKは、SecRandomCopyBytes
が戻り値として返す生成成否を判定していないという問題があります。Appleドキュメントでも注意されている通り、必ず戻り値を検査する必要があります(Always test the returned status to make sure that the array has been updated with new, random data before trying to use the values))。 - code_verifierには、32バイトの乱数をBASE64URLエンコードした結果である43バイトの文字列を設定しています。これは、PKCEを定めるRFC 7636で推奨される実装方法に従っています (It is RECOMMENDED that the output of a suitable random number generator be used to create a 32-octet sequence. The octet sequence is then base64url-encoded to produce a 43-octet URL safe string to use as the code verifier.
[RFC 7636. Section 4.1])。AppAuthやAuth0 SDKもこの推奨方法に準じた実装となっています。
生成したPKCE用データは、struct PKCE
インスタンスのメンバとして保持しており、後の認可要求およびトークン要求で用います。
クライアント/認可サーバ設定値
#if UNIVERSAL_LINK_USED
let redirectURIHost = "app.example.co.jp"
let redirectURIPath = "oauth2redirect/example-provider"
let redirectURI = "https://\(redirectURIHost)/\(redirectURIPath)"
let authenticationSessionCallback = ASWebAuthenticationSession.Callback.https(
host: redirectURIHost, path: redirectURIPath
)
#else
let customScheme = "com.example.app"
let redirectURI = "\(customScheme):/oauth2redirect/example-provider"
let authenticationSessionCallback = ASWebAuthenticationSession.Callback.customScheme(customScheme)
#endif
リダイレクトURIとして、ユニバーサルリンクを用いた方法と、それ以外としてカスタムURLスキームを用いた方法を条件コンパイルにより分けています。
ASWebAuthenticationSession.CallbackインスタンスのauthenticationSessionCallback
は、後述するWebAuthenticationSessionからアプリへコールバック (リダイレクト) させるために使用します。
認可要求URL組み立て
/// 認可エンドポイントへのリクエストURLを組み立てる
///
/// - Parameters:
/// - state: CSRF対策の乱数
/// - pkce: PKCE用データ
/// - Returns: 認可要求URL
func authorizationRequestURL(state: String, pkce: PKCE) -> URL {
var urlComponents = URLComponents(string: authorizationEndpoint)!
urlComponents.queryItems = [
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "code_challenge", value: pkce.challenge),
URLQueryItem(name: "code_challenge_method", value: pkce.method),
]
return urlComponents.url!
}
乱数state
はpkce.verifier
と同じ方法で生成しています。code_challenge
およびcode_challenge_method
がPKCEで用いるデータとなります。なお、サンプルではscope
パラメータを用いないので省略していますが、実アプリでは必要なscopeがあれば明示的に設定してください。認可サーバはscope未指定の要求に対してデフォルトスコープを設定するか、要求を失敗させるかどちらかの挙動をするようにOAuth仕様として規定されています (If the client omits the scope parameter when requesting authorization, the authorization server MUST either process the request using a pre-defined default value or fail the request indicating an invalid scope [RFC 6749. Section 3.3)。
組み立てられるリクエストURLは以下のようになります (見やすさのために改行しています):
https://auth.example.com/oauth2/authorize
?response_type=code
&client_id=xxxxxxxxxxxxxxxxxxxxxxxxxx
&redirect_uri=com.example.app:/oauth2redirect/example-provider
&state=MjHITUNrFsIXzGVfHWkYx3AD1JvDdSq2M1gpjDqwZhE
&code_challenge=BBb3pgxt60TJwcy_YdCdr7oZUoxCj6T1sUIQPgomTS4
&code_challenge_method=S256
認可要求開始
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
// ...中略...
func authorize() async throws -> Token {
let state = try randomBase64URLString(byteCount: 32)
let pkce = try PKCE()
// アプリ内にブラウザで認証セッションを開く
// 認可応答に含まれるリダイレクト先URLが戻り値として返る
let authorizationResponseURL = try await webAuthenticationSession.authenticate(
using: authorizationRequestURL(state: state, pkce: pkce),
callback: authenticationSessionCallback,
preferredBrowserSession: .ephemeral,
additionalHeaderFields: [:]
)
// ...後略...
}
ポイントは、webAuthenticationSession を用いている点です。webAuthenticationSession
は、SwiftUIのEnvironmentとして事前定義されたオブジェクトです。そのauthenticateメソッドを呼び出すことで、アプリ内で外部ユーザーエージェントを使って認証セッションを開始することができます。認証後に認可サーバが返す認可応答はwebAuthenticationSession.authenticate()
を呼び出したアプリ自体へリダイレクトされ、応答のURLは同メソッドの戻り値として取得できます。たとえ攻撃者のアプリが同一のリダイレクトURIを登録していても、認証セッションを呼び出したアプリへ返るので、本当の外部ブラウザアプリを用いる方法と比較して、認可コード横取り攻撃のリスクを低減させることができます。
authenticate()
メソッドの引数preferredBrowserSession
で、Cookie等のブラウザセッションをアプリと共有するかどうかを指定できます。共有しない場合はephemeral、共有する場合はsharedを指定してください。未指定の場合はshared
となります。共有設定の場合、セッション起動時にアプリとWebサイトで情報を共有してよいか確認ダイアログがユーザーに表示されます。ダイアログに同意するユーザーアクションがワンステップ増えますが、すでにブラウザに認証済みセッションがあれば認証情報を繰り返し入力する必要がなくなるのでSSOとしてユーザビリティが向上します。またショルダーハック等の手口でユーザーが入力する認証情報を盗み見られるリスクも減ります。共有の必要がない場合は、ephemeral
を設定しておけば不要なダイアログ確認を省略することができます。サービスのユースケースに応じて設定してください。
なおOAuthとは関係ありませんが、webAuthenticationSession.authenticate()
は、Swift Concurrencyに対応しており、asyncメソッドとして提供されています。completionHandlerによる複雑なコールバック処理を書く必要がないので、アプリケーションコードも見通しがよくなり可読性が上がることでしょう。
認可応答から認可コードを取得 (5)
まずは関連コード全体を示します。
func authorize() async throws -> Token {
// アプリ内にブラウザで認証セッションを開く
// 認可応答に含まれるリダイレクト先URLが戻り値として返る
let authorizationResponseURL = try await webAuthenticationSession.authenticate(
using: authorizationRequestURL(state: state, pkce: pkce),
callback: authenticationSessionCallback,
preferredBrowserSession: .ephemeral,
additionalHeaderFields: [:]
)
// 認可応答から認可コードを取得
let authorizationCode = try getAuthorizationCode(
from: authorizationResponseURL,
expectedState: state,
expectedRedirectURI: redirectURI
)
// ...後略...
}
/// 認可応答URLから認可コードを取得する
///
/// - Parameters:
/// - url: 認可応答URL
/// - expectedState: stateの期待値 (認可応答のstate値)
/// - expectedRedirectURI: リダイレクトURIの期待値
/// - Returns: 認可コード
/// - Throws: OAuthError.invalidAuthorizationResponse 認可応答不正時,
/// OAuthError.authorizationRequestFailed 認可応答にエラー情報が含まれる場合
func getAuthorizationCode(
from url: URL,
expectedState: String,
expectedRedirectURI: String
) throws -> String {
// 認可応答検証
try verifyAuthorizationResponse(
url: url,
expectedState: expectedState,
expectedRedirectURI: expectedRedirectURI
)
// 認可応答エラー情報確認
let queryItems = URLComponents(string: url.absoluteString)!.queryItems!
if let error = queryItems.first(where: { $0.name == "error" })?.value {
throw OAuthError.authorizationRequestFailed(
ErrorInfo(
error: error,
errorDescription: queryItems.first(where: { $0.name == "error_description" })?.value,
errorUri: queryItems.first(where: { $0.name == "error_uri" })?.value
)
)
}
// 認可応答のクエリパラメータから認可コードを取得
guard let code = queryItems.first(where: { $0.name == "code" })?.value else {
throw OAuthError.invalidAuthorizationResponse
}
return code
}
/// 認可応答の妥当性を検証する。不正な場合エラーを投げる
func verifyAuthorizationResponse(
url: URL,
expectedState: String,
expectedRedirectURI: String
) throws {
// リダイレクトURIの一致検証
guard url.absoluteString.hasPrefix(expectedRedirectURI + "?") else {
throw OAuthError.invalidAuthorizationResponse
}
// クエリパラメータの取得
guard let queryItems = URLComponents(string: url.absoluteString)?.queryItems else {
throw OAuthError.invalidAuthorizationResponse
}
// クエリパラメータからstateを取り出し、アプリが送信したstateと一致するか検証
guard queryItems.first(where: { $0.name == "state" })?.value == expectedState else {
throw OAuthError.invalidAuthorizationResponse
}
}
webAuthenticationSession.authenticate()
の戻り値として認可応答のLocationに指定されるリダイレクト先のURLが返されます。このURLのクエリパラメータcode
の値が認可コードであり、これを取得します。以下は認可応答のURL例です (見やすさのために改行しています)。
com.example.app:/oauth2redirect/example-provider
?code=b999e597-2ae1-4184-9740-70b94d737079
&state=MjHITUNrFsIXzGVfHWkYx3AD1JvDdSq2M1gpjDqwZhE
実際に認可コードを取得する前に、攻撃者が介在していないか以下のような認可応答の妥当性検証を行う必要があります。
リダイレクトURI一致検証
返されたURLが、認可応答で渡したリダイレクトURIと一致することを確認します。サンプルコードでは以下が該当します。
クエリ部開始を示す"?"までが、指定のリダイレクトURIと完全に合致することを単純検査しています。
guard url.absoluteString.hasPrefix(expectedRedirectURI + "?") else {
throw OAuthError.invalidAuthorizationResponse
}
なお、こういったURL一致検査のために提供されたのであろう ASWebAuthenticationSession.Callback.matchesURL(_:)は、カスタムURLスキームを用いるURLに対してパス部の一致確認まで行わないので、Mix-up攻撃耐性が不十分と判断して、サンプルコードでは意図的に使用していません。
state一致検証
以下が該当します。CSRF攻撃対策として認可要求で渡したstateとまったく同じ値が認可応答で返されることを確認します。
guard queryItems.first(where: { $0.name == "state" })?.value == expectedState else {
throw OAuthError.invalidAuthorizationResponse
}
認可応答エラー確認
正当な認可サーバからの応答であっても、認可要求が通らない場合にエラー情報がクエリパラメータで返される場合があります。認可コードを読み取る前にエラー有無を確認します。エラー応答の場合は、認可応答の問題ではないので、サンプルコードでは応答不正とは異なる種別のErrorを投げています。
if let error = queryItems.first(where: { $0.name == "error" })?.value {
throw OAuthError.authorizationRequestFailed(
error,
queryItems.first(where: { $0.name == "error_description" })?.value,
queryItems.first(where: { $0.name == "error_uri" })?.value
)
}
エラー応答の形式はOAuth仕様として標準化されており、RFC 6749 Section 4.1.2.1を参照ください。
トークン要求 (6)
まず関連するコードを示します。
/// トークンエンドポイントからアクセストークンを取得する
///
/// - Parameters:
/// - authorizationCode: 認可コード
/// - pkce: PKCE用データ
/// - Returns: トークン
/// - Throws: OAuthError.tokenRequestFailed トークン要求失敗時,
/// OAuthError.invalidTokenResponse トークン応答不正時
func requestAccessToken(
using authorizationCode: String,
pkce: PKCE
) async throws -> Token {
// トークン要求用のURLRequestを作成
let request = urlRequestForTokenRequest(authorizationCode: authorizationCode, pkce: pkce)
// トークン要求実行
let (payload, response) = try await URLSession.shared.data(for: request)
// ...後略...
}
func urlRequestForTokenRequest(authorizationCode: String, pkce: PKCE) -> URLRequest {
var req = URLRequest(url: URL(string: tokenEndpoint)!)
req.httpMethod = "POST"
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "content-type")
// リクエストパラメータ定義辞書
let bodyParams = [
"grant_type": "authorization_code",
"code": authorizationCode,
"redirect_uri": redirectURI,
"client_id": clientID,
"code_verifier": pkce.verifier,
]
// リクエストパラメータをボディに設定
req.httpBody = bodyParams.map { "\($0.key)=\($0.value)" }
.joined(separator: "&").data(using: .utf8)
return req
}
ネイティブアプリ観点で補足しておくべき点は、リクエストパラメータにclient_secret
等のシークレット情報を含めていないことです。不特定多数のユーザーが利用するネイティブアプリはpublicクライアントであり、クライアントシークレットがもはやシークレットではないので、シークレット情報を含めることにあまり意味がありません。RFC 8252では、クライアント認証のために認可サーバがシークレット情報を要求することを非推奨 (NOT RECOMMENDED) としています (もちろんクライアントを認可サーバへ登録する際にpublicクライアントとして登録しておく必要があります)。少し長いですが [RFC 8252. Section 8.5] から引用します。
Secrets that are statically included as part of an app distributed to multiple users should not be treated as confidential secrets, as one user may inspect their copy and learn the shared secret. For this reason, and those stated in Section 5.3.1 of [RFC6819], it is NOT RECOMMENDED for authorization servers to require client authentication of public native apps clients using a shared secret, as this serves little value beyond client identification which is already provided by the "client_id" request parameter.
また、リクエストパラメータとしてPKCE検査用のcode_verifier
を指定している点もポイントとなります。これによりアクセストークンが取得できるようになります。
トークン要求のメッセージ例は以下のようになります:
POST /oauth2/token HTTP/1.1
Host: auth.example.com
content-type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=b999e597-2ae1-4184-9740-70b94d737079
&redirect_uri=com%2Eexample%2Eapp%3A%2Foauth2redirect%2Fexample-provider
&client_id=xxxxxxxxxxxxxxxxxxxxxxxxxx
&code_verifier=VsJ1F14AfIfckkoKEHfU21MPodHIAFU1_14RJKHsub8
トークン応答 (7)
トークン応答処理は、特に興味深い点は無いと思います。応答のステータスコードが200であることを確認し、アクセストークンを含む応答ペイロードのJSONを所定の構造体へデコードしているだけです。これでアクセストークンの取得は完了です。
func requestAccessToken(
using authorizationCode: String,
pkce: PKCE
) async throws -> Token {
// ...中略...
// トークン要求実行
let (payload, response) = try await URLSession.shared.data(for: request)
// HTTPステータスコード確認
try verifyTokenResponseStatusCode(response, payload: payload)
// レスポンスペイロードのJSONをデコードしてアクセストークンを取得
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
do {
return try jsonDecoder.decode(Token.self, from: payload)
} catch {
throw OAuthError.invalidTokenResponse
}
}
/// トークン応答HTTPステータス確認
func verifyTokenResponseStatusCode(_ response: URLResponse, payload: Data) throws {
guard let statusCode = (response as? HTTPURLResponse)?.statusCode else {
throw OAuthError.invalidTokenResponse
}
guard statusCode == 200 else {
switch statusCode {
case 400:
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let errorInfo = try jsonDecoder.decode(ErrorInfo.self, from: payload)
throw OAuthError.tokenBadRequest(errorInfo)
} catch {
throw OAuthError.tokenRequestFailed(statusCode)
}
default:
throw OAuthError.tokenRequestFailed(statusCode)
}
}
}
なお、トークン応答のペイロード形式は、OAuth仕様としてRFC 6749. Section 5.1で定められています。対応する構造体定義は以下の通りですが、Swift観点で少し補足しておくと、ペイロードのJSONオブジェクトに含まれるプロパティ(パラメータ)名はsnake_caseですが、Swiftの識別子は慣習的にcamelCaseです。snake_caseからcamelCaseへの変換を自動化するために、JSONデコーダにconvertFromSnakeCaseを指定しています。これはエラー応答ペイロードも同様です (エラー応答のペイロード形式はRFC 6749. Section 5.2に定義があります。認可サーバ独自のエラー応答については認可サーバのドキュメントを参照ください)
struct Token: Decodable {
// 認可サーバのペイロード仕様に従い必要なパラメータを定義する
// ここではOAuth 2.0定義パラメータのみ定義
let accessToken: String
let tokenType: String
let expiresIn: Int?
let refreshToken: String?
let scope: String?
}
アプリケーション動作例
全く面白いものではありませんが、サンプルコードの動作例です。認可サーバにAmazon Cognitoを利用しています (認可サーバ/クライアントの設定情報もそれに合わせて書き換えています)。サンプルでは何が起きているかわかるようにアクセストークンの一部を画面に表示していますが、実際にはこんなことはしないでください (する人はいないと思いますが)。
ソースコード全体
ソースコードは MIT License に基づき、ご自由にご利用ください (Copyright (c) 2024 Jiro Matsuzawa)。
import SwiftUI
@main
struct OAuthExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
import AuthenticationServices
import SwiftUI
// 認可サーバURL
let baseURL = "https://auth.example.com"
let authorizationEndpoint = "\(baseURL)/oauth2/authorize"
let tokenEndpoint = "\(baseURL)/oauth2/token"
// クライアントID
let clientID = "xxxxxxxxxxxxxxxxxxxxxxxxxx"
// リダイレクトURI
#if UNIVERSAL_LINK_USED
let redirectURIHost = "app.example.co.jp"
let redirectURIPath = "oauth2redirect/example-provider"
let redirectURI = "https://\(redirectURIHost)/\(redirectURIPath)"
let authenticationSessionCallback = ASWebAuthenticationSession.Callback.https(
host: redirectURIHost, path: redirectURIPath
)
#else
let customScheme = "com.example.app"
let redirectURI = "\(customScheme):/oauth2redirect/example-provider"
let authenticationSessionCallback = ASWebAuthenticationSession.Callback.customScheme(customScheme)
#endif
enum OAuthError: Error {
case invalidAuthorizationResponse
case authorizationRequestFailed(ErrorInfo)
case invalidTokenResponse
case tokenRequestFailed(Int) // statusCode
case tokenBadRequest(ErrorInfo)
}
// 認可/トークン応答のエラー情報
// See RFC 6749 Section 4.1.2.1 and 5.2.
struct ErrorInfo: Decodable {
let error: String
let errorDescription: String?
let errorUri: String?
}
struct Token: Decodable {
// 認可サーバのペイロード仕様に従い必要なパラメータを定義する
// ここではOAuth 2.0定義パラメータのみ定義
let accessToken: String
let tokenType: String
let expiresIn: Int?
let refreshToken: String?
let scope: String?
}
struct ContentView: View {
@State var token: Token? = nil
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
var body: some View {
VStack {
Button("Sign in") {
Task {
do {
token = try await authorize()
} catch {
print(error.localizedDescription)
}
}
}
if let accessToken = token?.accessToken {
Text("Access token")
Text("\(accessToken)")
.frame(height: 20)
}
}
.font(.title)
.padding()
}
}
extension ContentView {
func authorize() async throws -> Token {
let state = try randomBase64URLString(byteCount: 32)
let pkce = try PKCE()
// アプリ内にブラウザで認証セッションを開く
// 認可応答に含まれるリダイレクト先URLが戻り値として返る
let authorizationResponseURL = try await webAuthenticationSession.authenticate(
using: authorizationRequestURL(state: state, pkce: pkce),
callback: authenticationSessionCallback,
preferredBrowserSession: .ephemeral,
additionalHeaderFields: [:]
)
// 認可応答から認可コードを取得
let authorizationCode = try getAuthorizationCode(
from: authorizationResponseURL,
expectedState: state,
expectedRedirectURI: redirectURI
)
// トークンエンドポイントへアクセストークンを要求
return try await requestAccessToken(using: authorizationCode, pkce: pkce)
}
/// 認可エンドポイントへのリクエストURLを組み立てる
///
/// - Parameters:
/// - state: CSRF対策の乱数
/// - pkce: PKCE用データ
/// - Returns: 認可要求URL
func authorizationRequestURL(state: String, pkce: PKCE) -> URL {
var urlComponents = URLComponents(string: authorizationEndpoint)!
urlComponents.queryItems = [
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "code_challenge", value: pkce.challenge),
URLQueryItem(name: "code_challenge_method", value: pkce.method),
]
return urlComponents.url!
}
/// 認可応答URLから認可コードを取得する
///
/// - Parameters:
/// - url: 認可応答URL
/// - expectedState: stateの期待値 (認可応答のstate値)
/// - expectedRedirectURI: リダイレクトURIの期待値
/// - Returns: 認可コード
/// - Throws: OAuthError.invalidAuthorizationResponse 認可応答不正時,
/// OAuthError.authorizationRequestFailed 認可応答にエラー情報が含まれる場合
func getAuthorizationCode(
from url: URL,
expectedState: String,
expectedRedirectURI: String
) throws -> String {
// 認可応答検証
try verifyAuthorizationResponse(
url: url,
expectedState: expectedState,
expectedRedirectURI: expectedRedirectURI
)
// 認可応答エラー情報確認
let queryItems = URLComponents(string: url.absoluteString)!.queryItems!
if let error = queryItems.first(where: { $0.name == "error" })?.value {
throw OAuthError.authorizationRequestFailed(
ErrorInfo(
error: error,
errorDescription: queryItems.first(where: { $0.name == "error_description" })?.value,
errorUri: queryItems.first(where: { $0.name == "error_uri" })?.value
)
)
}
// 認可応答のクエリパラメータから認可コードを取得
guard let code = queryItems.first(where: { $0.name == "code" })?.value else {
throw OAuthError.invalidAuthorizationResponse
}
return code
}
/// 認可応答の妥当性を検証する。不正な場合エラーを投げる
func verifyAuthorizationResponse(
url: URL,
expectedState: String,
expectedRedirectURI: String
) throws {
// リダイレクトURIの一致検証
guard url.absoluteString.hasPrefix(expectedRedirectURI + "?") else {
throw OAuthError.invalidAuthorizationResponse
}
// クエリパラメータの取得
guard let queryItems = URLComponents(string: url.absoluteString)?.queryItems else {
throw OAuthError.invalidAuthorizationResponse
}
// クエリパラメータからstateを取り出し、アプリが送信したstateと一致するか検証
guard queryItems.first(where: { $0.name == "state" })?.value == expectedState else {
throw OAuthError.invalidAuthorizationResponse
}
}
/// トークンエンドポイントからアクセストークンを取得する
///
/// - Parameters:
/// - authorizationCode: 認可コード
/// - pkce: PKCE用データ
/// - Returns: トークン
/// - Throws: OAuthError.tokenRequestFailed トークン要求失敗時,
/// OAuthError.invalidTokenResponse トークン応答不正時
func requestAccessToken(
using authorizationCode: String,
pkce: PKCE
) async throws -> Token {
// トークン要求用のURLRequestを作成
let request = urlRequestForTokenRequest(authorizationCode: authorizationCode, pkce: pkce)
// トークン要求実行
let (payload, response) = try await URLSession.shared.data(for: request)
// HTTPステータスコード検査
try verifyTokenResponseStatusCode(response, payload: payload)
// レスポンスペイロードのJSONをデコードしてアクセストークンを取得
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
do {
return try jsonDecoder.decode(Token.self, from: payload)
} catch {
throw OAuthError.invalidTokenResponse
}
}
func urlRequestForTokenRequest(authorizationCode: String, pkce: PKCE) -> URLRequest {
var req = URLRequest(url: URL(string: tokenEndpoint)!)
req.httpMethod = "POST"
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "content-type")
// リクエストパラメータ定義辞書
let params = [
"grant_type": "authorization_code",
"code": authorizationCode,
"redirect_uri": redirectURI,
"client_id": clientID,
"code_verifier": pkce.verifier,
]
// リクエストパラメータをボディに設定
req.httpBody = params.map { "\($0.key)=\($0.value)" }.joined(separator: "&").data(using: .utf8)
return req
}
/// トークン応答HTTPステータス確認
func verifyTokenResponseStatusCode(_ response: URLResponse, payload: Data) throws {
guard let statusCode = (response as? HTTPURLResponse)?.statusCode else {
throw OAuthError.invalidTokenResponse
}
guard statusCode == 200 else {
switch statusCode {
case 400:
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let errorInfo = try jsonDecoder.decode(ErrorInfo.self, from: payload)
throw OAuthError.tokenBadRequest(errorInfo)
} catch {
throw OAuthError.tokenRequestFailed(statusCode)
}
default:
throw OAuthError.tokenRequestFailed(statusCode)
}
}
}
}
import CryptoKit
import Foundation
enum CryptoError: Error {
case randomGenerationFailed
}
struct PKCE {
/// code_verifier
let verifier: String
/// code_challenge
let challenge: String
/// code_challenge_method
let method = "S256"
init() throws {
verifier = try Self.generateVerifier()
challenge = Self.computeChallenge(of: verifier)
}
/// PKCE用code_verifierを生成する
///
/// - Returns: 43バイトのcode_verifier
static func generateVerifier() throws -> String {
// 32バイト乱数をBASE64URLエンコードすると43バイトの文字列となる
return try randomBase64URLString(byteCount: 32)
}
/// PKCE用code_challengeを出力する
///
/// - Parameters:
/// - verifier: code_verifier
/// - Returns: verifierのSHA256ハッシュ値をBASE64URLエンコードした文字列
static func computeChallenge(of verifier: String) -> String {
let digest = SHA256.hash(data: Data(verifier.utf8))
return base64URL(of: Data(digest))
}
}
/// 指定バイト数の乱数を入力データとするBASE64URLエンコード文字列を生成する
///
/// - Parameters:
/// - byteCount: 入力データとなる乱数のバイト数
/// - Retunrs: BASE64URLエンコードした乱数文字列
func randomBase64URLString(byteCount: Int) throws -> String {
let random = try generateSecureRandomData(count: byteCount)
return base64URL(of: random)
}
/// 暗号学的 (cryptographically secure) 乱数を生成する
///
/// 乱数生成器として[SecRandomCopyBytes](https://developer.apple.com/documentation/security/secrandomcopybytes(_:_:_:)) を使用
///
/// - Parameters:
/// - count: 生成する乱数のバイト数
/// - Returns: 乱数
/// - Throws: `CryptoError.randomGenerationFailed` 乱数生成失敗時
func generateSecureRandomData(count: Int) throws -> Data {
var bytes = [UInt8](repeating: 0, count: count)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw CryptoError.randomGenerationFailed
}
return Data(bytes)
}
/// BASE64URLエンコードを行う
///
/// - Parameters:
/// - data: 入力データ
/// - Returns: BASE64URLエンコードした文字列
func base64URL(of data: Data) -> String {
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
参考資料
- RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
- RFC 6749 - The OAuth 2.0 Authorization Framework
- RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients
- RFC 8252 - OAuth 2.0 for Native Apps
- OAuth 2.0 Security Best Current Practice
- The OAuth 2.1 Authorization Framework
- Financial-grade API Security Profile 1.0 - Part 1: Baseline
- AppAuth-iOS
- Auth0.swift