Closed25

iOS アプリで Active Directory B2C による認証を行う

Kazunori KimuraKazunori Kimura

やりたいこと

  • アプリを起動した際に認証画面を表示する
  • サインアウトしたら認証画面を再表示する
  • バックグラウンドで定期的にアクセストークンを取得する

これを使う

GitHub - AzureAD/microsoft-authentication-library-for-objc: iOS および macOS 用の Microsoft 認証ライブラリ (MSAL)
https://github.com/AzureAD/microsoft-authentication-library-for-objc


完成したコード

Kazunori-Kimura/swiftui-msal-sample: SwiftUI + MSAL


参考

Azure AD B2C を使用して iOS Swift アプリでの認証を有効にする | Microsoft Learn
https://learn.microsoft.com/ja-jp/azure/active-directory-b2c/enable-authentication-ios-app

Kazunori KimuraKazunori Kimura

とりあえず Xcode で新しいプロジェクトを作成
SwiftUI を使う

後で Bundle Identifier を使用するので設定値をメモしておく

Kazunori KimuraKazunori Kimura

プロジェクトに MSAL を追加する

FileAdd Package Dependencies ... をクリックして https://github.com/AzureAD/microsoft-authentication-library-for-objc を追加する

DI したいので Factory も一緒に追加しておく

https://github.com/hmlongco/Factory

Kazunori KimuraKazunori Kimura

B2C にアプリを登録する

  • 名前: 任意の名前
  • サポートされているアカウントの種類: 任意の ID プロバイダーまたは組織ディレクトリ内のアカウント (ユーザー フローを使用したユーザーの認証用)
  • リダイレクト URI
    • ネイティブアプリ
    • msauth.[Your_Bundle_Id]://auth

Your_Bundle_Id は Xcode のプロジェクト作成時に指定した Bundle Identifier

Kazunori KimuraKazunori Kimura

登録したアプリの アプリケーション(クライアント)ID をメモしておく

Kazunori KimuraKazunori Kimura

クライアント シークレットの取得

  1. 左メニューの 証明書とシークレット をクリック
  2. クライアント シークレット新しいクライアント シークレット をクリック
  3. 説明に適当な値を入れてクライアントシークレットを追加
  4. ID が表示されるのでメモしておく(値はこのタイミングでしか表示できないので注意)

Web API が登録済みであれば、API のアクセス許可の付与 もしておく

  1. 左メニューの API のアクセス許可 をクリック
  2. + アクセス許可の追加 をクリック
  3. 自分のAPI からアプリから呼び出す API を選択
  4. アクセス許可 でチェックを入れて アクセス許可の追加 をクリック
  5. 〇〇に管理者の同意を与えます をクリック
Kazunori KimuraKazunori Kimura

認証に必要な設定を追加する
あわせて、xcconfig で定義した値を取得するために項目を追加する

  1. プロジェクトの設定 -> TARGETS でプロジェクト名の項目を選択 -> Info を選択
  2. Custom iOS Target Properties でリストを右クリックして Raw keys and values をクリック
  3. 以下のように項目を追加する
Key Type Value
CFBundleURLTypes Array
- (Item 0) Dictionary
-- CFBundleURLSchemes Array
--- (Item 0) String msauth.$(PRODUCT_BUNDLE_IDENTIFIER)
LSApplicationQueriesSchemes Array
- (Item 0) String msauthv2
- (Item 1) String msauthv3

以下は xcconfig の B2C に関する設定を受け取るための項目
xcconfig に Value に指定したものと同名の項目を用意する

Key Type Value
B2C_APP_ID String $(B2C_APP_ID)
B2C_AUTHORITY String $(B2C_AUTHORITY)
B2C_CLIENT_SECRET String $(B2C_CLIENT_SECRET)

Web API が存在する場合は以下を登録する

Key Type Value
API_SCHEME String https
API_BASE_URL String $(API_BASE_URL)
B2C_SCOPES Array
- (Item 0) String $(B2C_SCOPE_URL)

その他、xcconfig に定義した項目を swift から参照できるように追加する

Kazunori KimuraKazunori Kimura

あ、Info.plist を外部エディタで変更してから Xcode で再度更新すると上書きされて設定した値が失われる
Xcode のインターフェースが使いづらいんだけど...
いい方法あるか調査する。

Kazunori KimuraKazunori Kimura

Raw keys and values を選ぶのが重要っぽい。
デフォルトをこっちにしてくれればいいのに。

Kazunori KimuraKazunori Kimura

ログ出力の準備

Logger.swift
import OSLog

extension Logger {
    static let api = Logger(subsystem: Bundle.main.bundleIdentifier ?? "unknown", category: "API")
    static let auth = Logger(subsystem: Bundle.main.bundleIdentifier ?? "unknown", category: "Auth")
}
Kazunori KimuraKazunori Kimura

設定項目を簡単に取得できるようにラッパーメソッドを用意

Bundle+Extension.swift
import Foundation
import OSLog

extension Bundle {
    /**
     * Info.plist から String のデータを取得
     */
    static func getBundleString(key: String) -> String {
        guard let value = Bundle.main.object(forInfoDictionaryKey: key) else {
            return ""
        }
        Logger.auth.debug("\(#function): \(key) = \(value as! String)")
        return value as! String
    }
    
    /**
     * Info.plist から [String] のデータを取得
     */
    static func getBundleArray(key: String) -> [String] {
        guard let value = Bundle.main.object(forInfoDictionaryKey: key) else {
            return []
        }
        Logger.auth.debug("\(#function): \(key) = \(value as! [String])")
        return value as! [String]
    }
}
Kazunori KimuraKazunori Kimura

B2C での認証に必要な設定を管理するためのクラス

AuthCredentials.swift
import Foundation

class AuthCredentials {
    let authorityUrl: String
    let appId: String
    let clientSecret: String
    let scopes: [String]
    
    init() {
        self.authorityUrl = Bundle.getBundleString(key: "B2C_AUTHORITY")
        self.appId = Bundle.getBundleString(key: "B2C_APP_ID")
        self.clientSecret = Bundle.getBundleString(key: "B2C_CLIENT_SECRET")
        self.scopes = Bundle.getBundleArray(key: "B2C_SCOPES")
    }
}
Kazunori KimuraKazunori Kimura

ログイン状態およびアクセストークンを管理するクラス
ログイン状態に応じて View を更新したいので ObservableObject とする

AppContext.swift
import Foundation

class AppContext: ObservableObject {
    @Published var userIsLogedIn: Bool = false
    @Published var accessToken: String? = nil
}
Kazunori KimuraKazunori Kimura

定義したクラスは DI で使用したいので FactoryContainer を拡張する

Container.swift
import Factory

extension Container {
    var appContext: Factory<AppContext> {
        Factory(self) { AppContext() }
            .singleton
    }
    var authCredentials: Factory<AuthCredentials> {
        Factory(self) { AuthCredentials() }
            .singleton
    }
}
Kazunori KimuraKazunori Kimura

最上位の ViewController を探して返すメソッドを追加する。
認証画面を表示する際に使用される。

UIApplication+TopViewController.swift
import UIKit

extension UIApplication {
    static var topViewController: UIViewController? {
        // 'windows' was deprecated in iOS 15.0... と言う警告を消す
        // https://zenn.dev/paraches/articles/windows_was_depricated_in_ios15
        let scenes = UIApplication.shared.connectedScenes
        let window = (scenes.first as? UIWindowScene)?.windows.filter { $0.isKeyWindow }.first
        let rootVC = window?.rootViewController
        return rootVC?.top()
    }
}

private extension UIViewController {
    func top() -> UIViewController {
        if let nav = self as? UINavigationController {
            return nav.visibleViewController?.top() ?? nav
        } else if let tab = self as? UITabBarController {
            return tab.selectedViewController ?? tab
        } else {
            return presentedViewController?.top() ?? self
        }
    }
}
Kazunori KimuraKazunori Kimura

認証処理を提供するクラスを実装する
@Injected でインスタンスを注入する

AuthService.swift
import Foundation
import Factory
import MSAL
import OSLog

protocol AuthServiceProtocol {
    func setupView()
    func login(withInteraction: Bool)
    func logout()
    func openUrl(url: URL)
}

class AuthService: AuthServiceProtocol {
    @Injected(\.appContext)
    private var appContext
    @Injected(\.authCredentials)
    private var authCredentials
    
    // MSAL で使用
    private var context: MSALPublicClientApplication?
    private var webViewParamaters: MSALWebviewParameters?
    private var msAccount: MSALAccount?
    
    init() {
        do {
            try self.initContext()
        } catch let error {
            Logger.auth.error("\(#function): Failed to create MSALPublicClientApplication. \(String(describing: error))")
        }
    }
    
    // --- public ---
    
    /**
     * 認証 View の設定
     */
    func setupView() {
        Logger.auth.info(#function)
        if let viewController = UIApplication.topViewController {
            Logger.auth.debug("\(#function): create webViewParameters.")
            webViewParamaters = MSALWebviewParameters(authPresentationViewController: viewController)
        }
    }
    
    /**
     * ログインしてトークンを取得する
     */
    func login(withInteraction: Bool) {
        Logger.auth.info("\(#function): \(withInteraction)")
        
        loadAccount { account in
            // トークンを取得する
            if account != nil {
                // 認証画面を表示せずにトークンを更新
                self.acquireTokenSilently()
            } else if withInteraction {
                // 認証画面を表示する
                self.acquireTokenInteractively()
            } else {
                // ユーザー情報が確認できない
                // -> 認証データをクリアする
                self.logout()
            }
        }
    }
    
    /**
     * ログアウト
     */
    func logout() {
        Logger.auth.info(#function)
        guard let context = self.context,
              let account = self.msAccount
        else { return }
        
        guard let webViewParamaters = self.webViewParamaters else { return }
        
        let params = MSALSignoutParameters(webviewParameters: webViewParamaters)
        context.signout(with: account, signoutParameters: params) { (_, error) in
            if let error = error {
                Logger.auth.error("\(#function): \(String(describing: error))")
                return
            }
            
            self.msAccount = nil
            self.setAccount(nil)
            Logger.auth.info("\(#function): signout completed successfully.")
        }
    }
    
    /**
     * 認証完了後に呼ばれる
     */
    func openUrl(url: URL) {
        Logger.auth.info("\(#function): \(String(describing: url))")
        MSALPublicClientApplication.handleMSALResponse(url, sourceApplication: nil)
    }
    
    // --- private ---
    
    /**
     * MSALPublicClientApplication の初期化
     */
    private func initContext() throws {
        Logger.auth.debug("\(#function): \(String(describing: self.authCredentials.appId))")
        let authority = try MSALB2CAuthority(url: URL(string: authCredentials.authorityUrl)!)
        let redirectUri = "msauth.\(Bundle.main.bundleIdentifier!)://auth"
        Logger.auth.debug("\(#function): redirectUri = \(redirectUri)")
        
        let config = MSALPublicClientApplicationConfig(
            clientId: authCredentials.appId,
            redirectUri: redirectUri,
            authority: authority
        )
        config.knownAuthorities = [authority]
        context = try MSALPublicClientApplication(configuration: config)
    }
    
    /**
     * アカウントの読み込み
     */
    private func loadAccount(completion: ((MSALAccount?) -> Void)?) {
        guard let cntx = context else { return }
        
        let params = MSALParameters()
        params.completionBlockQueue = DispatchQueue.main
        
        cntx.getCurrentAccount(with: params) { (account, _, error) in
            if let error = error {
                Logger.auth.error("\(#function): \(String(describing: error))")
                return
            }
            
            if let account = account {
                Logger.auth.debug("\(#function): account = \(String(describing: account))")
                
                // アカウントデータを保持
                self.msAccount = account
                completion?(account)
                return
            }
            
            // アカウント情報が存在しない -> ログアウトした
            Logger.auth.debug("\(#function): signed out.")
            self.msAccount = nil
            self.setAccount(nil)
            completion?(nil)
        }
    }
    
    /**
     * アカウント情報を更新する
     */
    private func setAccount(_ account: MSALAccount?) {
        DispatchQueue.main.async {
            self.appContext.userIsLogedIn = account != nil
            if account == nil {
                self.appContext.accessToken = nil
            }
        }
    }
    /**
     * トークンの更新
     */
    private func setAccount(token: String?) {
        DispatchQueue.main.async {
            self.appContext.userIsLogedIn = token != nil
            self.appContext.accessToken = token
        }
    }
    
    /**
     * ログイン画面を表示してトークンを取得する
     */
    private func acquireTokenInteractively() {
        guard let context = self.context,
              let webViewParamaters = self.webViewParamaters else {
            return
        }
        
        let params = MSALInteractiveTokenParameters(
            scopes: self.authCredentials.scopes,
            webviewParameters: webViewParamaters)
        params.promptType = .selectAccount
        params.authority = try? MSALB2CAuthority(url: URL(string: self.authCredentials.authorityUrl)!)
        
        context.acquireToken(with: params) { (result, error) in
            if let error = error {
                Logger.auth.error("\(#function): \(String(describing: error))")
                return
            }
            
            guard let account = result?.account,
                  let token = result?.accessToken
            else {
                Logger.auth.error("\(#function): No result returned.")
                return
            }
            
            self.msAccount = account
            self.setAccount(token: token)
            
            Logger.auth.info("\(#function): AccessToken is acquired. (account = \(String(describing: account)), accessToken = \(token.prefix(5))...")
            dump(account)
        }
    }
    
    /**
     * トークンの更新を行う
     */
    private func acquireTokenSilently() {
        guard let context = self.context,
              let account = self.msAccount else {
            return
        }
        
        let params = MSALSilentTokenParameters(
            scopes: self.authCredentials.scopes,
            account: account)
        params.authority = try? MSALB2CAuthority(url: URL(string: self.authCredentials.authorityUrl)!)
        
        context.acquireTokenSilent(with: params) { (result, error) in
            if let error = error {
                if let nsError = error as NSError? {
                    if nsError.domain == MSALErrorDomain &&
                        nsError.code == MSALError.interactionRequired.rawValue {
                        Logger.auth.warning("\(#function): interaction required.")
                        // 対話的ログインが必要
                        DispatchQueue.main.async {
                            self.acquireTokenInteractively()
                        }
                        return
                    }
                }
                
                Logger.auth.error("\(#function): \(String(describing: error))")
                return
            }
            
            guard let account = result?.account,
                  let token = result?.accessToken else {
                Logger.auth.error("\(#function): No result returned.")
                return
            }
            
            self.msAccount = account
            self.setAccount(token: token)
            
            Logger.auth.info("\(#function): AccessToken is acquired.")
        }
    }
}
Kazunori KimuraKazunori Kimura

Login ボタンをクリックしたらログイン画面を表示
Logout ボタンをクリックしたら認証情報をクリアするよう実装

ContentView.swift
import SwiftUI
import Factory

struct ContentView: View {
    @EnvironmentObject var appContext: AppContext    
    @Injected(\.authService) private var authService
    
    var body: some View {
        VStack(spacing: 40) {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(appContext.userIsLogedIn ? "ログイン中": "ログインしてください")
                        
            Button("Login") {
                authService.login(withInteraction: true)
            }
            .disabled(appContext.userIsLogedIn)

            Button("Logout") {
                authService.logout()
            }
            .disabled(!appContext.userIsLogedIn)
        }
        .padding()
    }
}
Kazunori KimuraKazunori Kimura

アプリがアクティブになったタイミングで認証を行う
認証画面は表示しない

MsalSampleApp.swift
import SwiftUI
import Factory

@main
struct MsalSampleApp: App {
    @Environment(\.scenePhase) var scenePhase
    @Injected(\.authService) var authService
    @Injected(\.appContext) var appContext
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                    authService.setupView()
                    authService.login(withInteraction: false)
                }
                .onChange(of: scenePhase, initial: false) { _, scenePhase in
                    if scenePhase == .active {
                        authService.login(withInteraction: false)
                    }
                }
                .onOpenURL { url in
                    authService.openUrl(url: url)
                }
                .environmentObject(appContext)
        }
    }
}
このスクラップは2024/02/16にクローズされました