アイリッジ開発者ブログ

アイリッジに所属するエンジニアが技術情報を発信していきます。

OAuthを利用できる特殊なアプリ内ブラウザ ASWebAuthenticationSession について

OAuthを利用できる特殊なアプリ内ブラウザ ASWebAuthenticationSession について

開発部 iOSエンジニア 山崎です。

認証画面を表示しユーザーの認証結果を受け取れる、OAuth認証を利用できる特殊なアプリ内ブラウザ ASWebAuthenticationSession を利用したので解説します。

OAuth認証(オーオース認証)とは

一つのサービスでログインしたら、他のサービスでも再度ID・パスワードを入力しなくても自動でログインしてくれる仕組みのことです。

例えばiOSでは、自社で用意したバックエンドでOAuth認証をしたり、「Github」など外部サービスのログインの際にOAuth認証をしたい、というケースが考えられます。

iOSでの実装方法

OAuth認証について、iOSにおいてはWKWebViewを使うことでもやろうと思えば実現できます。が、ASWebAuthenticationSessionの方が安全らしく、Appleはこちらの使用を推奨しています。

Should I use WKWebView or SFSafariViewController for web views in my app?

These two APIs can provide a lot of the heavy lifting for web technologies in your app, though there are a few instances where we recommend alternative frameworks. For example, when presenting a web-based login screen for your app, use ASWebAuthenticationSession to provide people with the most secure experience.

ほぼこのASWebAuthenticationSession一択だと思いますが、デメリットとしては後に上げるようにUIのカスタマイズが一切できないという点になります。これは許容する必要があります。

また、WKWebViewでは、ブラウザの新しいタブを開くようなリンク(いわゆるtarget="_blank")をうまく開けるよう対応させることができますが、ASWebAuthenticationSessionではそのような操作はできません。これも許容する必要があるかと思います。

メリットとしては、上述のように安全で、導入も簡単だということでしょう。

基本の使い方

// ExampleViewController.swift
// 1.セッションの作成
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: request.callbackURLScheme) { callbackURL, error in
                // 4.セッション実行完了後にこのコールバックが呼ばれる
                guard error == nil else {
                    // 5.エラーハンドリングを行う
                    return
                }
                
                //6.得られたコールバックURLを用いて何かを行う.
}

// 2.ContextProvider設定 認証画面をどこのUIWindowを起点に開きたいかを指定する。iOS13以上で必要。
session.presentationContextProvider = self
// 3.セッションスタート。これにより、OS側が勝手に認証画面を開き、ユーザーに認証を求める
session.start()

1.セッションの作成

ASWebAuthenticationSessionクラスをインスタンス化してください。該当ページのURLをイニシャライザに渡します。コールバックURLスキーマ という引数も渡す必要がありますが、該当アプリのカスタムURLスキーマとして設定されている必要はないようです。コールバック内でのちに返ってくるURLのスキーマとして想定しているものを渡すようです。

2. ContextProvider設定

sessionのContextProvider変数を設定します。設定する値はASWebAuthenticationPresentationContextProvidingプロトコルに適合している必要があります。iOS13以上で必要となります。iOS13以上の場合、設定していないと動作しません。

以下のように、UIViewControllerを適合させると良いかと思います。これを設定することで、どのUIWindowを起点に認証画面のブラウザを表示させるかを決めることができます。

// UIViewController+ASWebAuthenticationPresentationContextProviding.swift
extension UIViewController: ASWebAuthenticationPresentationContextProviding {
    /// どのUIWindowから認証画面を表示させるか指定
    public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        return view.window ?? Default.shared.keyWindow()    
    }
}

3.セッションスタート

session.start()を呼ぶことにより、OS側が勝手に認証画面を開き、ユーザーに認証を求めます。典型的には何らかのシステムの認証画面が開かれ、ユーザー名やパスワードを入力し、ユーザーが認証を行うことになります。

内部的にはSFSafariViewControllerが使われていることから、WKWebViewのように該当画面の見た目をカスタマイズすることはできませんし、読み込まれたURLをフックしてさまざまな処理を行うなどのカスタマイズもできません。あくまで、認証結果を受け取れるだけです。

まず、ダイアログがOSにより表示されます。

続けるをユーザーがタップした場合、OSがブラウザを自動的に開きます。以下のブラウザの見た目を変更することはできません。

例えば、全画面表示にしたり、タイトルを変えたり、ナビゲーションバーの色を変えたり、ナビゲーションバーのボタンを変えたりといったことはできません。

また、下の画像では塗りつぶしていますが、表示したいページのURLが表示されます。WKWebViewなどとは違い、URLが表示されるので、見た目が目的のページにそっくりなフィッシングサイトなどを踏んでしまった時の対策となり、安全性に優れているといえるでしょう。

4.セッション実行完了後 コールバック

セッション実行完了後(認証が完了したり、逆に認証を諦めて画面を閉じた場合など)、sessionにおいて指定したコールバック(クロージャ)が呼ばれます。

クロージャの引数としてコールバックURLスキーマとエラーが返ってきますので、これを使ってさらに処理を進めていきます。

5. エラー処理

error引数がnilかどうかをチェックしましょう。nilでない場合は何らかのエラーが発生したということですので、エラーハンドリングをすると良いでしょう。例えば、エラーをユーザーに知らせるダイアログを表示させるなどです。

ユーザーが認証画面を閉じた場合などにエラーが返ってくるようです。

6.得られたコールバックURLを用いて何かを行う

ここで認証成功した際にサーバーから返されるコールバックURLをチェックします。用件に基づいて必要な処理を行いましょう。

要件によっては、以降の処理はサーバ側で行うため、アプリ側では何もする必要がない、という場合もあるでしょう。

利用しやすくコード上でラップしてみた例

毎回以上の処理を書くのは面倒です。いろいろラップの方法はあると思いますが、ここではあたかもMoyaのAPIクライアントのように、どこからでも呼び出せるようにしてみました。

ASWebAuthenticationClient

シングルトンオブジェクトです。基本となるASWebAuthenticationSessionのハンドリングを行います。

// ASWebAuthenticationClient.swift
import AuthenticationServices
import Foundation
import RxCocoa
import RxSwift

/// OAuth認証をASWebAuthenticationSessionを使って行うクライアント
final class ASWebAuthenticationClient {
    static let shared = ASWebAuthenticationClient()

    var session: ASWebAuthenticationSession?

    private init() {}

    func request<G: ASWebAuthenticationTargetType>(_ request: G) -> Single<G.Response> {
        return Single.create { [weak self] observer in
            Logger.debug("request start: \(String(describing: request.completeURL))")

            guard let url = request.completeURL else {
                observer(.error(MyError.clientError))
                return Disposables.create()
            }

            // Initialize the session.
            self?.session = ASWebAuthenticationSession(url: url, callbackURLScheme: request.callbackURLScheme) { callbackURL, error in
                // 認証終了後にこのコールバックが呼ばれる
                guard error == nil else {
                    // エラーイベント送信
                    observer(.error(MyError.general))
                    return
                }

                guard let callbackURL = callbackURL,
                      let response = G.Response(callbackURL: callbackURL) else {
                     // エラーイベント送信
                    observer(.error(MyError.general))
                    return
                }

                observer(.success(response))
            }
            // ContextProvider設定
            self?.session?.presentationContextProvider = request.getContextProvider()
            // ASWebAuthenticationSessionのスタートにより、OS側が勝手に認証画面を開き、ユーザーに認証を求める模様。
            self?.session?.start()
            return Disposables.create()
        }
    }

    func requestStub<G: ASWebAuthenticationTargetType>(_ request: G) -> Single<G.Response> {
        return Single.create { observer in
            guard let response = request.sampleData else {
                // エラーイベント送信
                 observer(.error(MyError.general))
                return Disposables.create()
            }
            observer(.success(response))
            return Disposables.create()
        }
    }
}

ASWebAuthenticationTargetType

クライアントがOAuth認証を行うにあたり、必要な詳細情報を定義するクラスを「TargetType」とします。TargetTypeが準拠すべきプロトコルです。

// ASWebAuthenticationTargetType.swift
import AuthenticationServices
import Foundation

/// The protocol used to define the specifications necessary for a `ASWebAuthenticationClient`.
protocol ASWebAuthenticationTargetType {

    /// ASWebAuthenticationが成功した結果、返して欲しい目的の型
    associatedtype Response: ASWebAuthenticationResponseProtocol

    /// ASWebAuthenticationに渡すURLのベース
    var base: String { get }

    /// ASWebAuthenticationに渡すURLのパス
    var path: String { get }

    /// ASWebAuthenticationに渡すURLのクエリパラメータ
    var queryItems: [URLQueryItem] { get }

    /// ASWebAuthenticationに渡すURLスキーマ デフォルト実装があり、通常は実装不要
    var callbackURLScheme: String? { get }

    /// ASWebAuthenticationに渡す完全なURLが何か デフォルト実装があり、base, path, queryItemから生成するので通常は実装不要
    var completeURL: URL? { get }

    /// サンプルとして返させたいデータがあれば渡してください
    var sampleData: Response? { get }

    /// ASWebAuthenticationPresentationContextProvidingを返させる
    /// どこからWebViewを表示するかの情報(UIWindow)を提供させるため、必要
    func getContextProvider() -> ASWebAuthenticationPresentationContextProviding?
}

extension ASWebAuthenticationTargetType {
    var callbackURLScheme: String? {
        // URLスキーマのinfo.plistへの設定は不要な模様なので設定していない
        return "my-auth"
    }

    // ベースURL、パス、クエリパラメータからOAuth認証ページのURLを生成する
    var completeURL: URL? {
        guard let baseURL = URL(string: base + path) else { return nil }
        var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true)
        components?.queryItems = queryItems
        return components?.url
    }
}

ASWebAuthenticationResponseProtocol

OAuth認証が成功した場合にコールバックで得るべき情報を定義するプロトコルです。OAuth認証が成功した場合はURLが渡されるので、例えば、そのURLのクエリパラメータに何らかの必要な情報が入っており、そこからアプリで使う構造体を生成するようなものを想定しています。

// ASWebAuthenticationResponseProtocol.swift
import Foundation

protocol ASWebAuthenticationResponseProtocol {
    /// ASWebAuthenticationを実行した結果得られるURLから、どのように目的の型を初期化するのかを定義すること
    init?(callbackURL: URL)

    /// ASWebAuthenticationを実行した結果得られるURL
    /// initializerで渡されたものを設定しておく想定
    var callbackURL: URL { get }
}

ASWebAuthenticationPresentationContextProvider

ASWebAuthenticationPresentationContextProviding プロトコルに準拠した型です。

毎回、ViewControllerをこのプロトコルに準拠させて渡すのも面倒なため、代わりにこの型を用いてKeyWindowを探し、それを起点に認証WebViewを出させるような実装としています。

// ASWebAuthenticationPresentationContextProvider.swift
import AuthenticationServices
import Foundation

// swiftlint:disable type_name
/// NSObjectに準拠させないとエラーになる
final class ASWebAuthenticationPresentationContextProvider: NSObject {
    // swiftlint:enable type_name

    private override init() {}

    static let shared = ASWebAuthenticationPresentationContextProvider()
}

extension ASWebAuthenticationPresentationContextProvider: ASWebAuthenticationPresentationContextProviding {
    /// どのUIWindowから認証画面を表示させるか指定
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        // KeyWindowを取得
        return Default.shared.keyWindow()
    }
}

LoginTargetTypeフォルダ

ここでは、例えばログイン用のOAuth認証を行うと仮定して、ログイン用のTargetTypeを実装しましょう。

LoginEntity

ログイン用のOAuth認証により得られるコールバックから取得したい情報です。コールバックURLに「info」というクエリパラメータが含まれていると仮定して、そこから情報を取得するようにしています。

// LoginEntity.swift
import Foundation

struct LoginEntity: ASWebAuthenticationResponseProtocol {

   // OAuth認証が成功した後のコールバックURLから、この型をイニシャライズします。
    init?(callbackURL: URL) {
        // コールバックURLのクエリパラメータ「info」よりデータを取得します。
        self.callbackURL = callbackURL
        guard let components = URLComponents(string: callbackURL.absoluteString),
              let queries = components.queryItems,
              let info = queries.first(where: { $0.name == "info" })?.value else {
            return nil
        }

        self.info = info
    }

    var callbackURL: URL
    var info: String
}

LoginTargetType

上に記載したASWebAuthenticationTargetTypeに準拠した型です。ログインOAuth認証のため、必要な詳細情報を定義していきます。

// LoginTargetType.swift
struct LoginTargetType: ASWebAuthenticationTargetType {
     // OAuth認証成功時に欲しい型(構造体)
    typealias Response = LoginEntity

    weak var contextProvider: ASWebAuthenticationPresentationContextProviding?

    init(
        contextProvider: ASWebAuthenticationPresentationContextProviding
    ) {
        self.contextProvider = contextProvider
    }
    
    // OAuth認証ページのURLのベース
    var base: String {
        API.baseURL
    }
    
    // OAuth認証ページのURLのパス
    var path: String {
        "/hogehoge/login/"
    }

    // OAuth認証ページのURLのクエリパラメータ
    var queryItems: [URLQueryItem] {
        var result = [
            URLQueryItem(name: "hogehoge", value: "hogehoge"),
        ]
        return result
    }

    // テストサンプルデータ
    var sampleData: LoginEntity? {
        // OAuth認証の結果"my-auth://logindone?info=test"というコールバックURLが返ってきたと仮定してテストデータを生成
        return .init(
                callbackURL:
                    URL(string: "my-auth://logindone?info=test")!
            )
    }

    func getContextProvider() -> ASWebAuthenticationPresentationContextProviding? {
        contextProvider
    }
}

使用例

// LoginDataStore.swift
final class LoginDataStore {

        /// ログインを行う 外部からcontextProviderを渡したい場合はこちら
        func login(contextProvider: ASWebAuthenticationPresentationContextProviding) -> Single<LoginEntity> {
        ASWebAuthenticationClient.shared
            .request(LoginTargetType(
                contextProvider: contextProvider
            ))
    }
    
     /// ログインを行う (引数省略版)
        func login() -> Single<LoginEntity> {
        login(
            contextProvider: ASWebAuthenticationPresentationContextProvider.shared
        )
    }
}

参考