【SwiftUI MSAL】ログイン状態が保持されない時に疑うこと

SwiftUIで開発中のiOSアプリで、[MSAL](https://github.com/AzureAD/microsoft-authentication-library-for-objc)を使用して認証を行っています。

TestFlightで配信しているテスト版を使っている開発メンバーの中で、ログイン状態が保持される人と保持されない人がおり、原因追及した結果、みんな結構ハマりそうな所が原因だったので、状況と解決策を書きたいと思います。少しでも困っているiOS開発者の救いになればと思っています。

:::note warn
実装の詳細説明は今回省いているので結構抽象的な記事かもです。今度気が向いたらSwiftUIアプリにMSALを導入する手順を0から100まで記事にしようと思っています。いいねかコメントいただけたらやる気出ます。
:::

# 環境
```
Xcode : Version 14.2
MSAL  : Version 1.2.10
```

# 状況
AzureADのテナントを開発用と本番用の２つに切り分けて開発しています。
MSAL導入の機能実装中は、開発用テナントにテストユーザーでログインしながら作業。その後ある程度機能実装を終え、いざ本番テナントに切り替え本物ユーザーでログイン。無事に認証ができることを確認しました。

その後はチョコチョコとAzureADテナントを切り替えて、テストユーザーでログインしたり、本物ユーザーでログインしたりを繰り返しながら実装を続けていました。

※👇ログイン時のシュミレーターのイメージ。
実機の時は`Microsoft Authenticator`でのログインを有効にしています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/9f21e6d6-6453-1aa6-17c1-80fead381978.png" width="250">
そんなことをしていると、`テナント`と`ログインユーザー`がテレコになって間違えてログインしようとしてしまうことが結構何回もありました。間違えて`本番テナント`に`テストユーザー`でログインしちゃう、とか。

**👆これが悲劇の始まりでした。**

# 原因
### MSALは良くも悪くも、ログインユーザーをキャッシュする

`MSAL`はログインしたユーザーに加え
「ログインし損ねたユーザー」をもキャッシュします。
ここが肝なのでもう一度言います。

**「ログインし損ねたユーザー」をもキャッシュします**

ログインユーザーがキャッシュから解放されるタイミングは、**「ログアウトした時」** です。
テナントが違うためログインし損ねたユーザーのキャッシュは残ります。つまり

**そのユーザーのキャッシュはなにもしないと、一生残り続けます**

キャッシュされているユーザーが複数いるため、`Silently`でログインするときに`MSAL`が
「お前誰やねん。どのユーザーやねん」って分からないので自動ログインができず、ログイン状態が保持されていない様な状況に陥っていたわけです。

# 実装全体のイメージ
説明の都合上、一応実装の全体イメージも載せておきます。
解説はまた別の機会にします。

<details>
<summary>全体コード</summary>

```swift
import Combine
import UIKit
import MSAL

class MSALProvider {
    @Published var userIsLogedIn: Bool = false
    let kClientID = "XXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    let kRedirectUri = "msauth.com.Hoge://auth"
    let kAuthority = "https://login.microsoftonline.com/organizations"
    let kGraphEndpoint = "https://graph.microsoft.com/"
    let kScopes = ["api://kClientID/Hoge.Read"]
    var accessToken: String? = nil
    var graphAPIAccessToken: String? = nil
    var currentAccount: MSALAccount?
    var applicationContext: MSALPublicClientApplication? = nil
    
    init() {
        do {
            try self.initMSAL()
        } catch (let err) {
            print("init error: \(err.localizedDescription)")
        }
        
        self.loadCurrentAccount { account in
            guard let currentAccount = account else { return }
            self.acquireTokenSilently(currentAccount)
        }
    }
    
    private func initMSAL() throws {
        guard let authorityURL = URL(string: kAuthority) else { return }
        
        let authority = try MSALAADAuthority(url: authorityURL)
        let msalConfiguration = MSALPublicClientApplicationConfig(
            clientId: kClientID,
            redirectUri: kRedirectUri,
            authority: authority
        )
        self.applicationContext = try MSALPublicClientApplication(configuration: msalConfiguration)
    }
    
    private func loadCurrentAccount(completion: @escaping (MSALAccount?) -> Void) {
        guard let applicationContext = self.applicationContext else { return }
        
        let msalParameters = MSALParameters()
        msalParameters.completionBlockQueue = DispatchQueue.main
        
        applicationContext.getCurrentAccount(with: msalParameters) { currentAccount, previousAccount, error in
            if error != nil { return }
            
            if let currentAccount = currentAccount {
                self.currentAccount = currentAccount
                completion(currentAccount)
                return
            }
            completion(nil)
        }
    }
    
    private func acquireTokenInteractively(webViewParameters: MSALWebviewParameters) {
        guard let applicationContext = self.applicationContext else { return }
        
        let parameters = MSALInteractiveTokenParameters(scopes: kScopes, webviewParameters: webViewParameters)
        
        applicationContext.acquireToken(with: parameters) { result, error in
            if error != nil { return }
            guard let result = result else { return }
            
            self.accessToken = result.accessToken
            self.currentAccount = result.account
            self.acquireGraphAPITokenSilently(result.account)
        }
    }
    
    private func acquireTokenSilently(_ account : MSALAccount!) {
        guard let applicationContext = self.applicationContext else { return }
        
        let parameters = MSALSilentTokenParameters(scopes: self.kScopes, account: account)
        
        applicationContext.acquireTokenSilent(with: parameters) { result, error in
            if error != nil { return }
            guard let result = result else { return }
            
            self.accessToken = result.accessToken
            self.acquireGraphAPITokenSilently(result.account)
        }
    }
    
    private func acquireGraphAPITokenSilently(_ account: MSALAccount!) {
        guard let applicationContext = self.applicationContext else { return }
        
        let parameters = MSALSilentTokenParameters(scopes: ["User.Read"], account: account)
        
        applicationContext.acquireTokenSilent(with: parameters) { result, error in
            if error != nil { return }
            guard let result = result else { return }
            
            self.graphAPIAccessToken = result.accessToken
            self.userIsLogedIn = true
        }
    }
    
    func login(with viewController: UIViewController) {
        let webViewParameters = MSALWebviewParameters(authPresentationViewController: viewController)
        self.acquireTokenInteractively(webViewParameters: webViewParameters)
    }
    
    func logout() {
        guard let applicationContext = self.applicationContext else { return }
        guard let account = self.currentAccount else { return }
        
        let signoutParameters = MSALSignoutParameters()
        signoutParameters.signoutFromBrowser = false
        
        applicationContext.signout(with: account, signoutParameters: signoutParameters) { success, error in
            self.accessToken = nil
            self.currentAccount = nil
            self.userIsLogedIn = false
            self.graphAPIAccessToken = nil
        }
    }
}
```

</details>

# キャッシュされているユーザーを確認する

:::note warn
MSALの実装が完了している想定で書いています。ご容赦ください。
:::
下記コードを、MSAL初期化後とかに仕込んで実行してみてください。上記の全体コードで言うと`initMSAL()`メソッドの最後です。
`MSALPublicClientApplication`（上の全体コードでは`applicationContext`として定義）が持つ`accounts()`メソッドを用いて、現在キャッシュされているアカウント数とusernameを確認できます。

```swift
 do {
    // キャッシュ中のアカウントをget
    let accounts = try applicationContext.accounts(for: MSALAccountEnumerationParameters())
    print("chaced accounts: \(accounts.count) accounts")
    try accounts.forEach { account in
        print(account.username)
    }
} catch (let error) {
    print(error.localizedDescription)
}
```
正常な状態ならば、下記の様に現在ログインしているアカウントのみがキャッシュされていると思います。
```bash
chaced accounts: 1 accounts
Optional("本番 太郎")
```

しかし私の様に間違えて`本番テナント`に`テストユーザー`でログインしようとし、結果当然のようにログインできないという様な動作をしたことがあると、下記の様に複数のアカウントがキャッシュされています。
```bash
chaced accounts: 2 accounts
Optional("本番　太郎")
Optional("手須戸　二郎")
```

`logout`メソッドの最後にも同様にこのコードを仕込むと、正常にログアウトしたアカウントはキャッシュから解放され、ログアウトできていないアカウントのみが残ると思います。

**これが全ての原因でした...💣**

# 解決方法
要は、**余計なアカウントを残さないためにキャッシュを削除する** 処理を書けばOKです
下記メソッドを定義します。

```swift
 private func clearAccountsCache() {
    guard let applicationContext = self.applicationContext else { return }
    
    do {
        let accounts = try applicationContext.accounts(for: MSALAccountEnumerationParameters())
        try accounts.forEach { account in
            try applicationContext.remove(account)
        }
    } catch (let error) {
        print("Clear chace error: \(error.localizedDescription)")
    }
}
```
`applicationContext.remove(account)`は、`MSALPublicClientApplication`インスタンスが持つ`remove`メソッドを使って、アカウントのキャッシュを削除しています。

私の場合は上記全コードで言うと、下記のように`logout()`内と`loadCurrentAccount()`内でこのメソッドを呼んでいます。

```swift
// loadCurrentAccountメソッド

private func loadCurrentAccount(completion: @escaping (MSALAccount?) -> Void) {
    guard let applicationContext = self.applicationContext else { return }
    
    let msalParameters = MSALParameters()
    msalParameters.completionBlockQueue = DispatchQueue.main
    
    applicationContext.getCurrentAccount(with: msalParameters) { currentAccount, previousAccount, error in
        if let error = error {
            // ★追加★
            self.clearAccountsCache()
            print(error.localizedDescription)
            return
        }
        
        if let currentAccount = currentAccount {
            self.currentAccount = currentAccount
            completion(currentAccount)
            return
        }
        completion(nil)
    }
}
```

```swift
// logoutメソッド
func logout() {
    guard let applicationContext = self.applicationContext else { return }
    guard let account = self.currentAccount else { return }
    
    let signoutParameters = MSALSignoutParameters()
    signoutParameters.signoutFromBrowser = false
    
    applicationContext.signout(with: account, signoutParameters: signoutParameters) { success, error in
        self.accessToken = nil
        self.currentAccount = nil
        self.userIsLogedIn = false
        self.graphAPIAccessToken = nil
    }
    
    // ★追加★
    clearAccountsCache()
}
```
そうすることで、
- ログアウト時に、全てのアカウントのキャッシュを削除できる
- ログイン時に、`getCurrentAccount`メソッドがerrorを吐く場合（つまりキャッシュが複数あるとき）にキャッシュを削除できる

という振る舞いを実現でき、この不具合を解消できます。

一応コード全文も載せておきます

<details>
<summary>全体コード</summary>

```swift
import Combine
import UIKit
import MSAL

class MSALProvider {
    @Published var userIsLogedIn: Bool = false
    let kClientID = "XXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    let kRedirectUri = "msauth.com.Hoge://auth"
    let kAuthority = "https://login.microsoftonline.com/organizations"
    let kGraphEndpoint = "https://graph.microsoft.com/"
    let kScopes = ["api://kClientID/Hoge.Read"]
    var accessToken: String? = nil
    var graphAPIAccessToken: String? = nil
    var currentAccount: MSALAccount?
    var applicationContext: MSALPublicClientApplication? = nil
    
    init() {
        do {
            try self.initMSAL()
        } catch (let err) {
            print("init error: \(err.localizedDescription)")
        }
        
        self.loadCurrentAccount { account in
            guard let currentAccount = account else { return }
            self.acquireTokenSilently(currentAccount)
        }
    }
    
    private func initMSAL() throws {
        guard let authorityURL = URL(string: kAuthority) else { return }
        
        let authority = try MSALAADAuthority(url: authorityURL)
        let msalConfiguration = MSALPublicClientApplicationConfig(
            clientId: kClientID,
            redirectUri: kRedirectUri,
            authority: authority
        )
        self.applicationContext = try MSALPublicClientApplication(configuration: msalConfiguration)
    }
    
    private func loadCurrentAccount(completion: @escaping (MSALAccount?) -> Void) {
        guard let applicationContext = self.applicationContext else { return }
        
        let msalParameters = MSALParameters()
        msalParameters.completionBlockQueue = DispatchQueue.main
        
        applicationContext.getCurrentAccount(with: msalParameters) { currentAccount, previousAccount, error in
            if let error = error {
                self.clearAccountsCache()
                print(error.localizedDescription)
                return
            }
            
            if let currentAccount = currentAccount {
                self.currentAccount = currentAccount
                completion(currentAccount)
                return
            }
            completion(nil)
        }
    }
    
    private func acquireTokenInteractively(webViewParameters: MSALWebviewParameters) {
        guard let applicationContext = self.applicationContext else { return }
        
        let parameters = MSALInteractiveTokenParameters(scopes: kScopes, webviewParameters: webViewParameters)
        
        applicationContext.acquireToken(with: parameters) { result, error in
            // ★追加★
            if let error = error {
                self.clearAccountsCache()
                print(error.localizedDescription)
                return
            }
            guard let result = result else { return }
            
            self.accessToken = result.accessToken
            self.currentAccount = result.account
            self.acquireGraphAPITokenSilently(result.account)
        }
    }
    
    private func acquireTokenSilently(_ account : MSALAccount!) {
        guard let applicationContext = self.applicationContext else { return }
        
        let parameters = MSALSilentTokenParameters(scopes: self.kScopes, account: account)
        
        applicationContext.acquireTokenSilent(with: parameters) { result, error in
            if error != nil { return }
            guard let result = result else { return }
            
            self.accessToken = result.accessToken
            self.acquireGraphAPITokenSilently(result.account)
        }
    }
    
    private func acquireGraphAPITokenSilently(_ account: MSALAccount!) {
        guard let applicationContext = self.applicationContext else { return }
        
        let parameters = MSALSilentTokenParameters(scopes: ["User.Read"], account: account)
        
        applicationContext.acquireTokenSilent(with: parameters) { result, error in
            if error != nil { return }
            guard let result = result else { return }
            
            self.graphAPIAccessToken = result.accessToken
            self.userIsLogedIn = true
        }
    }
    
    func login(with viewController: UIViewController) {
        let webViewParameters = MSALWebviewParameters(authPresentationViewController: viewController)
        self.acquireTokenInteractively(webViewParameters: webViewParameters)
    }
    
    func logout() {
        guard let applicationContext = self.applicationContext else { return }
        guard let account = self.currentAccount else { return }
        
        let signoutParameters = MSALSignoutParameters()
        signoutParameters.signoutFromBrowser = false
        
        applicationContext.signout(with: account, signoutParameters: signoutParameters) { success, error in
            self.accessToken = nil
            self.currentAccount = nil
            self.userIsLogedIn = false
            self.graphAPIAccessToken = nil
        }
        
        // ★追加★
        clearAccountsCache()
    }
    
    // ★追加★
    private func clearAccountsCache() {
        guard let applicationContext = self.applicationContext else { return }
        
        do {
            let accounts = try applicationContext.accounts(for: MSALAccountEnumerationParameters())
            try accounts.forEach { account in
                try applicationContext.remove(account)
            }
        } catch (let error) {
            print("Logout error: \(error.localizedDescription)")
        }
    }
}
```
</details>

# おわりに

まじでめちゃくちゃハマりました。
ログインできなかったユーザーはキャッシュすんなよって思います。
誰かの役に立てば幸いです。

### 参考

https://learn.microsoft.com/en-us/azure/active-directory/develop/msal-java-get-remove-accounts-token-cache