SwiftUIで開発中のiOSアプリで、MSALを使用して認証を行っています。
TestFlightで配信しているテスト版を使っている開発メンバーの中で、ログイン状態が保持される人と保持されない人がおり、原因追及した結果、みんな結構ハマりそうな所が原因だったので、状況と解決策を書きたいと思います。少しでも困っているiOS開発者の救いになればと思っています。
実装の詳細説明は今回省いているので結構抽象的な記事かもです。今度気が向いたらSwiftUIアプリにMSALを導入する手順を0から100まで記事にしようと思っています。いいねかコメントいただけたらやる気出ます。
環境
Xcode : Version 14.2
MSAL : Version 1.2.10
状況
AzureADのテナントを開発用と本番用の2つに切り分けて開発しています。
MSAL導入の機能実装中は、開発用テナントにテストユーザーでログインしながら作業。その後ある程度機能実装を終え、いざ本番テナントに切り替え本物ユーザーでログイン。無事に認証ができることを確認しました。
その後はチョコチョコとAzureADテナントを切り替えて、テストユーザーでログインしたり、本物ユーザーでログインしたりを繰り返しながら実装を続けていました。
※👇ログイン時のシュミレーターのイメージ。
実機の時はMicrosoft Authenticator
でのログインを有効にしています。
そんなことをしていると、テナント
とログインユーザー
がテレコになって間違えてログインしようとしてしまうことが結構何回もありました。間違えて本番テナント
にテストユーザー
でログインしちゃう、とか。
👆これが悲劇の始まりでした。
原因
MSALは良くも悪くも、ログインユーザーをキャッシュする
MSAL
はログインしたユーザーに加え
「ログインし損ねたユーザー」をもキャッシュします。
ここが肝なのでもう一度言います。
「ログインし損ねたユーザー」をもキャッシュします
ログインユーザーがキャッシュから解放されるタイミングは、「ログアウトした時」 です。
テナントが違うためログインし損ねたユーザーのキャッシュは残ります。つまり
そのユーザーのキャッシュはなにもしないと、一生残り続けます
キャッシュされているユーザーが複数いるため、Silently
でログインするときにMSAL
が
「お前誰やねん。どのユーザーやねん」って分からないので自動ログインができず、ログイン状態が保持されていない様な状況に陥っていたわけです。
実装全体のイメージ
説明の都合上、一応実装の全体イメージも載せておきます。
解説はまた別の機会にします。
全体コード
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
}
}
}
キャッシュされているユーザーを確認する
MSALの実装が完了している想定で書いています。ご容赦ください。
下記コードを、MSAL初期化後とかに仕込んで実行してみてください。上記の全体コードで言うとinitMSAL()
メソッドの最後です。
MSALPublicClientApplication
(上の全体コードではapplicationContext
として定義)が持つaccounts()
メソッドを用いて、現在キャッシュされているアカウント数とusernameを確認できます。
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)
}
正常な状態ならば、下記の様に現在ログインしているアカウントのみがキャッシュされていると思います。
chaced accounts: 1 accounts
Optional("本番 太郎")
しかし私の様に間違えて本番テナント
にテストユーザー
でログインしようとし、結果当然のようにログインできないという様な動作をしたことがあると、下記の様に複数のアカウントがキャッシュされています。
chaced accounts: 2 accounts
Optional("本番 太郎")
Optional("手須戸 二郎")
logout
メソッドの最後にも同様にこのコードを仕込むと、正常にログアウトしたアカウントはキャッシュから解放され、ログアウトできていないアカウントのみが残ると思います。
これが全ての原因でした...💣
解決方法
要は、余計なアカウントを残さないためにキャッシュを削除する 処理を書けばOKです
下記メソッドを定義します。
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()
内でこのメソッドを呼んでいます。
// 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)
}
}
// 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を吐く場合(つまりキャッシュが複数あるとき)にキャッシュを削除できる
という振る舞いを実現でき、この不具合を解消できます。
一応コード全文も載せておきます
全体コード
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)")
}
}
}
おわりに
まじでめちゃくちゃハマりました。
ログインできなかったユーザーはキャッシュすんなよって思います。
誰かの役に立てば幸いです。
参考