iOS アプリで Active Directory B2C による認証を行う
やりたいこと
- アプリを起動した際に認証画面を表示する
- サインアウトしたら認証画面を再表示する
- バックグラウンドで定期的にアクセストークンを取得する
これを使う
GitHub - AzureAD/microsoft-authentication-library-for-objc: iOS および macOS 用の Microsoft 認証ライブラリ (MSAL)
完成したコード
Kazunori-Kimura/swiftui-msal-sample: SwiftUI + MSAL
参考
Azure AD B2C を使用して iOS Swift アプリでの認証を有効にする | Microsoft Learn
alschmut/MSALSwiftUI: A SwiftUI representation of the official MSAL UIKit Sample app
Factory | Documentation
とりあえず Xcode で新しいプロジェクトを作成
SwiftUI を使う
後で Bundle Identifier を使用するので設定値をメモしておく
プロジェクトに MSAL を追加する
File → Add Package Dependencies ... をクリックして https://github.com/AzureAD/microsoft-authentication-library-for-objc
を追加する
DI したいので Factory も一緒に追加しておく
https://github.com/hmlongco/Factory
B2C にアプリを登録する
- 名前: 任意の名前
-
サポートされているアカウントの種類:
任意の ID プロバイダーまたは組織ディレクトリ内のアカウント (ユーザー フローを使用したユーザーの認証用)
-
リダイレクト URI
ネイティブアプリ
msauth.[Your_Bundle_Id]://auth
Your_Bundle_Id
は Xcode のプロジェクト作成時に指定した Bundle Identifier
登録したアプリの アプリケーション(クライアント)ID をメモしておく
クライアント シークレットの取得
- 左メニューの 証明書とシークレット をクリック
- クライアント シークレット で 新しいクライアント シークレット をクリック
- 説明に適当な値を入れてクライアントシークレットを追加
- 値 と ID が表示されるのでメモしておく(値はこのタイミングでしか表示できないので注意)
Web API が登録済みであれば、API のアクセス許可の付与 もしておく
- 左メニューの API のアクセス許可 をクリック
- + アクセス許可の追加 をクリック
- 自分のAPI からアプリから呼び出す API を選択
- アクセス許可 でチェックを入れて アクセス許可の追加 をクリック
- 〇〇に管理者の同意を与えます をクリック
クライアントシークレットを xcconfig ファイルにまとめる
以下の記事を参考に
Xcode の Build Configuration を使って API の接続情報を管理する
GitHub - AzureAD/microsoft-authentication-library-for-objc: Microsoft Authentication Library (MSAL) for iOS and macOS の README Adding MSAL to your project を参考にセットアップする
keychain group を追加する
- プロジェクトの設定を開く
- TARGETS を選択
- Signing & Capabilities を選択
- + Capability をクリック
- Keychain Sharing をダブルクリック
-
com.microsoft.adalcache
を追加
認証に必要な設定を追加する
あわせて、xcconfig で定義した値を取得するために項目を追加する
- プロジェクトの設定 -> TARGETS でプロジェクト名の項目を選択 -> Info を選択
- Custom iOS Target Properties でリストを右クリックして Raw keys and values をクリック
- 以下のように項目を追加する
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 から参照できるように追加する
あ、Info.plist
を外部エディタで変更してから Xcode で再度更新すると上書きされて設定した値が失われる
Xcode のインターフェースが使いづらいんだけど...
いい方法あるか調査する。
なさそう。
Xcode でメンテナンスする。
Raw keys and values を選ぶのが重要っぽい。
デフォルトをこっちにしてくれればいいのに。
ログ出力の準備
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")
}
設定項目を簡単に取得できるようにラッパーメソッドを用意
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]
}
}
auth
ログにしてるけど、app
とか common
みたいなカテゴリーを追加したい
B2C での認証に必要な設定を管理するためのクラス
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")
}
}
ログイン状態およびアクセストークンを管理するクラス
ログイン状態に応じて View を更新したいので ObservableObject
とする
import Foundation
class AppContext: ObservableObject {
@Published var userIsLogedIn: Bool = false
@Published var accessToken: String? = nil
}
accessToken
は @Published
にする必要ないかも
定義したクラスは DI で使用したいので Factory の Container
を拡張する
import Factory
extension Container {
var appContext: Factory<AppContext> {
Factory(self) { AppContext() }
.singleton
}
var authCredentials: Factory<AuthCredentials> {
Factory(self) { AuthCredentials() }
.singleton
}
}
最上位の ViewController を探して返すメソッドを追加する。
認証画面を表示する際に使用される。
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
}
}
}
認証処理を提供するクラスを実装する
@Injected
でインスタンスを注入する
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.")
}
}
}
Login ボタンをクリックしたらログイン画面を表示
Logout ボタンをクリックしたら認証情報をクリアするよう実装
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()
}
}
アプリがアクティブになったタイミングで認証を行う
認証画面は表示しない
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)
}
}
}