【Swift】iOSアプリでWeb認証画面を実装する方法まとめ

# はじめに
こんばんは。iOS開発経験4ヶ月の初心者です🔰。

「iOS開発未経験」、「そもそも認証とは？」な有様なので、iOSのWeb認証（OpenID Connect）の実装に苦悩しています。

本稿では、iOSでのWeb認証サービスを介した認証を実現するにあたり、Webの認証画面をアプリ上に表示する際の実装方法について紹介します。

初心者の勉強のついで程度の内容なので、皆様の役に立つアウトプットかは分からないです...

# ネイティブアプリにおける認証
 （詳しい方は読み飛ばしてください）
 
ネイティブアプリなのにWebで認証ってどういうこと？とガチの初学者の私は疑問に思いました。
 
ネイティブアプリにおいて、OAuth2.0（OpenID Connect）で認証する際は、__Webで提供されている認証サービスをネイティブアプリケーションから認証サービスを利用すること__ が現状のベストプラクティスなようです（[RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252)）。

違ってたらすみません。

アプリでメールアドレスとパスワードを入力するフォームを作れば、ネイティブだけで完結するので便利と思われるかもしれませんが、__Webを経由して認証を通す形__ で実装します。


#### 簡単にまとめると、以下のようなメリットがあるようです。

- ブラウザが持つSSOセッションや認証コンテキストを活用できる
- Google、Facebook、X（旧Twitter）など、サードパーティの認証サービスを利用できる
- 既成のWebアプリをiOSアプリにも移植する場合などで、WebとiOSで一貫したUXを提供できる
- 他のアプリケーションやブラウザと認証状態が共有でき、優れたUXを提供できる。
- パスワード・マネージャーの機能を使用できるため、UXが向上する。

# iOSからWeb認証を行う実装方法
iOSにおける認証ですが、いくつかの実装方法があります。
いずれも **Webの認証サービスの画面をアプリ上に表示し、認証を通す（必要ならネイティブアプリに戻ってくる）** といったものになります。

大体の流れは以下のようになります。
1. **Web認証サービスの表示** : アプリ内でWebベースの認証サービスを表示し、ユーザーにログインさせる
2. **認証情報の取得** : 認証が完了すると、認証サーバーからトークンなどの認証情報が提供される
3. **認証情報を使用した情報へのアクセス** : 取得した認証情報を使って、アプリ内でユーザーの情報を取得したり、サービスにアクセスしたりする


手法としては、「WebViewでネイティブの画面を直接表示する」方法、「Safariなどの標準ブラウザを立ち上げ、そちらで認証画面を表示する」方法、そして「アプリ内ブラウザ（In-App Browser）で表示する方法」の3つが存在するようです。

|認証方法  |実装手法| メリット  |デメリット  |
|---|---|---|---|
|アプリ内ブラウザ（In-App Browser  |ASWebAuthenticationSession、<br> SFSafariViewController|・アプリ内ブラウザを使用するため、Safariなどの外部アプリに飛ばされず、ユーザー側の操作がアプリ内で完結させられる<br>・ASWebAuthenticationは安全性が高く、シングルサインオンやプライバシー保護の機能が備わっている|・Webを表示する部分にどうしてもデフォルトのiOSアプリ感のある枠が表示される。さらに、そのデザインに手を加えられない。|
|Default Browser  |Safari|・ユーザーが使い慣れたブラウザで操作が可能。<br>・外部ブラウザとして独立しているため、アプリが認証情報を不正に取得するリスクが低減される  |・アプリが切り替わるため、どうしてもUXが低い |
|WebViewでネイティブ画面上に表示   |WKWebView| ・アプリ内でブラウジングまでを詳細にカスタマイズできる （JavaScriptを実行可能）<br> ・UIを統一することができる|・認証情報をアプリに直接入力するため、それらの情報を抜き取るような実装が可能なためユーザーが信頼できない|

ユーザー体験が途切れず、安全性が高いらしいアプリ内ブラウザが良い感じがします。

## ASWebAuthenticationSession
これらの中で暫定的に良さそうな、ASWebAuthenticationSessionの挙動を見ていきます。

ASWebAuthenticationSessionの初期化認証WebサービスのURLを指定することで、画面の下からモーダル的にアプリ内ブラウザが出現し、認証操作を行うことになります。

https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession

こんな感じになりました（認証サービス側は、適当にKeycloakで作成）。


![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3473225/60b4745b-da50-dc4f-4bab-11a5a40dbeed.gif)


---

### 実装
以下のコードを使って、簡単に実装することができます。認可コードフローです。
OP側は、Keycloakを使って動作を確認しています（OP側の説明は省略します...:bow:）。

- ContentsView.swift
- LoginView.swift
- AuthView.swift

---
### ContentsView.swift
ContentsViewでは、ログイン画面へのNavigationを行っています。

```swift: ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack {
                NavigationLink("ログイン") {
                    LoginView()
                }
            }
        }
    }
}
```
---
### AuthSessionView.swift
AuthSessionViewでは、ASWebAuthenticationSessionの初期化を行い、Webの認証セッションを開始します。ユーザーは認証プロセスを経て、アプリにリダイレクトされ、認証結果（通常はコールバックURL内の情報）がアプリに返されます。

リダイレクトの際に渡されるコールバックURLに認可コードがくっついてくるので、任意の場所で認可コードをハンドリングする処理を記述します。

> - callback：このViewの呼び出し元で、認証セッションから返ってくるURLを処理する記述を書きます。
>- authURL：ASWebAuthenticationSessionを初期化する際に渡す、認証サービスのURLです。
> - customURLScheme：アプリに戻るためのカスタムURLスキーマを指定しています。


```swift:AuthSessionView.swift
import SwiftUI
import AuthenticationServices

struct AuthSessionView: UIViewControllerRepresentable {
    var callback: (URL) -> Void
    
    let authURL = "http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?response_type=code&scope=openid%20email&client_id=myclient&redirect_uri=authapp://auth-callback"
    
    let customURLScheme = "authapp"
    
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    func makeUIViewController(context: Context) -> UIViewController {
        let viewController = UIViewController()
        guard let url = URL(string: authURL) else {
            return viewController
        }
        
        let session = ASWebAuthenticationSession(url: url, callbackURLScheme: customURLScheme) { callbackURL, error in
            if let callbackURL {
                callback(callbackURL)
            } else if let error {
                fatalError(error.localizedDescription)
            }
        }
        
        session.prefersEphemeralWebBrowserSession = true
        
        session.presentationContextProvider = context.coordinator
        
        session.start() // 認証セッション開始、アプリ内ブラウザ起動
        
        return viewController
    }
    
    func updateUIViewController(_: UIViewController, context _: Context) {}
}

class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
    var parent: AuthSessionView
    
    init(parent: AuthSessionView) {
        self.parent = parent
    }
    
    func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {
        let scenes = UIApplication.shared.connectedScenes
        let windowScene = scenes.first as? UIWindowScene
        guard let window = windowScene?.windows.first else {
            fatalError("No windows in the application")
        }
        return window
    }
}

```
---
### LoginView.swift
LoginViewでは、AuthSessionViewを呼び出し、実際に認証画面を表示します。
また、AuthSessionViewのcallbackクロージャをここで呼び出すことで、取得した認可コードをこのView内で確認する処理を書いています。


```swift:LoginView.swift
import SwiftUI

struct LoginView: View {
    @State private var code: String?
    
    var body: some View {
        NavigationView {
            VStack {
                Text("ログイン画面")
                    .font(.title)
                
                Spacer()
                
                if let code = self.code {
                    Text("ログイン済み")
                    Text("\(code)")
                } else {
                    Text("未ログイン")
                    AuthSessionView { callbackURL in
                        self.code = getCode(callbackURL: callbackURL)
                    }
                } 
                
                Spacer()
            }
        }
    }
    
    func getCode(callbackURL: URL) -> String? {
        guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
              let queryItems = components.queryItems
        else {
            return nil
        }
        if let codeValue = queryItems.first(where: { $0.name == "code" })?.value {
            print("Code value: \(codeValue)")
            return codeValue
        } else {
            return nil
        }
    }
}
```


# おわりに
コメント・ご指摘をいただけますと非常にありがたいです！何卒よろしくお願いいたします。
いつか、ログアウトやセッションの終了についても書けたらいいなと思います...:bow:

## 参考
- [OAuth 2.0 for Native Apps](https://techinfoofmicrosofttech.osscons.jp/index.php?OAuth%202.0%20for%20Native%20Apps#f98074ab)
- [Documentation - Authentication Services](https://developer.apple.com/documentation/authenticationservices)