🐶

PWA Builderで生成したiOSプロジェクトでAuth.jsのSNS認証を通す方法

2024/11/07に公開

想定読者

  1. NextjsプロジェクトをPWA化している方
  2. PWAをApp Storeにリストしようとされている方(Google Playも)
  3. Web側の認証実装はAuth.js(旧NextAuth)を使っている方

執筆背景

タイトルを実現するために、検索を繰り返し、AIに聞いてみたり、公式Discordに潜り込んでDiscussionを追うなど...2,3日死ぬほど試行錯誤ののちなんとか実装に成功したため、備忘録としても同じ問題にぶちあたった方が同じ地獄を味合わないための記事を目指しております。今回はGoogleログイン、Appleログインでの実装は成功していますが、メールアドレス/パスワードは試していません。

PWA Builderとは

PWA Builderというサービスがあります。PWA Builderは既存のWebサービスにするためのPWAサポート(PWAに必要なmanifestファイルや複数サイズのアイコン生成など)やiOS, Androidネイティブ用のパッケージを生成してくれる神サービスです。

https://www.pwabuilder.com/

PWA Builderによるネイティブパッケージの生成は基本的にWebViewベースとなっており、言ってしまえば「ガワアプリ」として振る舞うことができるため、PWA本来の実装のまま、アプリストアに載せることができると言った利点があります。
(そのため、UIの一部をネイティブ特有の挙動に置き換えるなどの実装を行いたい場合は不向きです)

そういう意味で、ある意味脳死でWebプロジェクトをアプリ化できるのが、PWA Builderの利点と言えるのですが、一点だけ困ることがあります。それがタイトルにも記載した認証です。

PWA式ネイティブアプリの認証

ちなみに、WebからPWAにしたアプリとAndroidに関しては基本的に挙動は問題なく動きます(一部authjs側に対応が必要な処理があったかもしれませんが割と簡単に対応できるはず)。問題なのはiOSアプリの部分です。

Authjs(厳密にいうとauthjs v5)ベースのOAuth認証の場合、iOSアプリ側からの呼び出しがPWA時と挙動が変わってしまいます。具体的には、Googleログインを実装している場合は、リダイレクトが変える際に400エラーが表示されます。

実際は、この後認証画面を閉じると、ログインに成功(セッション情報が受け取られている?)して問題なく触れはするのですが、UX的には大変よろしくない(かつ恐らく審査に通らない)ため、自然な認証体験をさせる必要があります。

[結論] 対応方法(Swift側の実装)

結論、Web側で実装しているAuthjsを使わずに、アプリ側で認証をさせてその認証情報をWeb側に渡すという方法で対応可能です(というかこれ以外なさそうです)。

これは、以下のGithubのIssueで挙げられている対応方法を中心に一部コードをカスタマイズさせるやり方です。(ただし、ここで挙げられているサンプルコードは抽象的で理解するまでに時間がかかりました)
https://github.com/pwa-builder/PWABuilder/issues/2433#issuecomment-1506436865

では具体的に説明していくのですが、

恐らくPWAでiOSアプリを実装したい方のほとんどはiOSのネイティブ言語であるSwift自体をあまり知らないと思うため簡単に説明すると、Swiftで実装するWebViewはWebView側とアプリ側で情報をやり取りすることが一応できるみたいです。

まず、PWA Builderで生成したテンプレートパッケージで更新が必要な箇所は、ViewControllerとWebViewの2ファイル(+今回新規作成する1ファイル)です。

  1. ViewController.swift内に以下のような記載があります。
ViewController
extension ViewController: WKScriptMessageHandler {
  func userContentController(
    _ userContentController: WKUserContentController, didReceive message: WKScriptMessage
  ) {
    ...(省略)
    if message.name == "push-subscribe" {
      handleSubscribeTouch(message: message)
    }
    if message.name == "push-permission-request" {
      handlePushPermission()
    }
    if message.name == "push-permission-state" {
      handlePushState()
    }

これらはWeb側からmessageを受け取った際の挙動を記述する部分となっており、受け取るmessage.nameは以下の部分で定義されています。

WebView.swift
func createWebView(
  container: UIView, WKSMH: WKScriptMessageHandler, WKND: WKNavigationDelegate, NSO: NSObject,
  VC: ViewController
) -> WKWebView {

  let config = WKWebViewConfiguration()
  let userContentController = WKUserContentController()

  userContentController.add(WKSMH, name: "push-subscribe")
  userContentController.add(WKSMH, name: "push-permission-request")
  userContentController.add(WKSMH, name: "push-permission-state")
  userContentController.add(WKSMH, name: "push-token")

userContentController.add(WKSMH, name: "push-subscribe")のような部分です。

ここにWeb側でサインインボタンを押された時に

(1)アプリ側で認証依頼を受け取る
(2)アプリ側で認証
(3)認証結果をWebに返す

の流れを実装します。

A. アプリ側で認証依頼を受け取る

WebView.swift
func createWebView(
  container: UIView, WKSMH: WKScriptMessageHandler, WKND: WKNavigationDelegate, NSO: NSObject,
  VC: ViewController
) -> WKWebView {

  let config = WKWebViewConfiguration()
  let userContentController = WKUserContentController()

  userContentController.add(WKSMH, name: "push-subscribe")
  userContentController.add(WKSMH, name: "push-permission-request")
  userContentController.add(WKSMH, name: "push-permission-state")
  userContentController.add(WKSMH, name: "push-token")
+ userContentController.add(WKSMH, name: "socialAuth")
Viewcontroller.swift
extension ViewController: WKScriptMessageHandler {
  func userContentController(
    _ userContentController: WKUserContentController, didReceive message: WKScriptMessage
  ) {
+    if message.name == "socialAuth" {
+      let socialAuth = SocialAuth()
+      if let provider = message.body as? String {
+         socialAuth.signIn(provider: "google")
+        }
+      }
+    }
    if message.name == "push-subscribe" {
      handleSubscribeTouch(message: message)
    }
    if message.name == "push-permission-request" {
      handlePushPermission()
    }
    if message.name == "push-permission-state" {
      handlePushState()
    }
    if message.name == "push-token" {
      handleFCMToken()
    }
  }
}

B. アプリ側で認証

上記1でViewcontroller内に記載したSocialAuth()も新しく実装します。ここはコピペでOK(多分)これはGithub Issueの内容を踏襲しています。
*関数名は書いてありますが、FaceBookの実装はしていません。以下はGoogleとAppleログインのみの実装です。

SocialAuth.swift
import AuthenticationServices
import Foundation
import GoogleSignIn
import SwiftUI

class SocialAuth: NSObject, ASAuthorizationControllerDelegate,
  ASAuthorizationControllerPresentationContextProviding
{
  private static var shared: SocialAuth?

  func signIn(provider: String) {
    switch provider {
    case "google":
      googleAuth()
    case "apple":
      appleAuth()
    case "facebook":
      facebookAuth()
    default:
      print("Unknown provider: \(provider)")
    }
  }

  private func googleAuth() {
    guard let rootViewController = UIApplication.shared.windows.first?.rootViewController else {
      print("There is no root view controller!")
      return
    }

    GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { signInResult, error in
      guard let signInResult = signInResult else {
        print("Error! \(String(describing: error))")
        return
      }
      let accessToken = signInResult.user.idToken!.tokenString
      let email = signInResult.user.profile!.email
      let userId = signInResult.user.userID!
      let fullName = signInResult.user.profile?.name ?? ""

      self.sendToJavaScript(
        accessToken: accessToken, userId: userId, name: fullName, email: email, provider: "google")

      if let viewController = rootViewController as? ViewController {
        DispatchQueue.main.async {
          viewController.overrideUIStyle()
          viewController.setNeedsStatusBarAppearanceUpdate()
        }
      }
    }
  }

  private var authController: ASAuthorizationController?

  private func appleAuth() {
    print("Starting Apple Auth...")
    Self.shared = self

    let provider = ASAuthorizationAppleIDProvider()
    let request = provider.createRequest()
    request.requestedScopes = [.fullName, .email]

    authController = ASAuthorizationController(authorizationRequests: [request])
    print("Setting delegate: \(self)")
    authController?.delegate = self
    authController?.presentationContextProvider = self

    // デリゲートの設定を確認
    if let controller = authController {
      print("Delegate is set: \(controller.delegate != nil)")
      print("PresentationContextProvider is set: \(controller.presentationContextProvider != nil)")
    }
    print("Performing auth request...")
    authController?.performRequests()
  }

  // MARK: - ASAuthorizationControllerDelegate
  func authorizationController(
    controller: ASAuthorizationController,
    didCompleteWithAuthorization authorization: ASAuthorization
  ) {
    print("=== Authorization Success ===")
    if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
      print("Received credential: \(appleIDCredential)")

      let userId = appleIDCredential.user
      var fullName = ""
      var email = ""

      if let givenName = appleIDCredential.fullName?.givenName,
        let familyName = appleIDCredential.fullName?.familyName
      {
        fullName = "\(givenName) \(familyName)"
        print("New user detected with full name")
      }

      if let userEmail = appleIDCredential.email {
        email = userEmail
        print("New user detected with email")
      }

      if fullName.isEmpty {
        fullName = UserDefaults.standard.string(forKey: "apple_user_\(userId)_name") ?? ""
        print("Retrieved name from UserDefaults: \(fullName)")
      } else {
        UserDefaults.standard.set(fullName, forKey: "apple_user_\(userId)_name")
      }

      if email.isEmpty {
        email = UserDefaults.standard.string(forKey: "apple_user_\(userId)_email") ?? ""
        print("Retrieved email from UserDefaults: \(email)")
      } else {
        UserDefaults.standard.set(email, forKey: "apple_user_\(userId)_email")
      }

      var accessToken = ""
      if let identityToken = appleIDCredential.identityToken,
        let tokenString = String(data: identityToken, encoding: .utf8)
      {
        accessToken = tokenString
      }

      self.sendToJavaScript(
        accessToken: accessToken,
        userId: userId,
        name: fullName,
        email: email,
        provider: "apple"
      )

      Self.shared = nil
    }
  }

  func authorizationController(
    controller: ASAuthorizationController, didCompleteWithError error: Error
  ) {
    print("=== Authorization Error ===")
    print("Error: \(error)")
    print("LocalizedDescription: \(error.localizedDescription)")
    if let authError = error as? ASAuthorizationError {
      print("ASAuthorizationError code: \(authError.code.rawValue)")
    }
    Self.shared = nil
  }

  // MARK: - ASAuthorizationControllerPresentationContextProviding
  func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
    print("=== Presentation Anchor Requested ===")
    guard let window = UIApplication.shared.windows.first else {
      print("Warning: Using fallback window")
      return UIWindow()
    }
    print("Providing window: \(window)")
    return window
  }

  private func facebookAuth() {
    // TODO: Implement Facebook sign-in
    //    self.sendToJavaScript(
    //      accessToken: "facebookAccessToken", email: "facebook@example.com", provider: "facebook")
  }

  private func sendToJavaScript(
    accessToken: String, userId: String, name: String, email: String, provider: String
  ) {
    let data = [
      "accessToken": accessToken,
      "userId": userId,
      "name": name,
      "email": email,
      "provider": provider,
    ]
    guard let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []) else {
      print("Error: could not create JSON data")
      return
    }
    let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
    let jsCode =
      "this.dispatchEvent(new CustomEvent('external-social-auth', { detail: \(jsonString) }))"
    yourProject.webView.evaluateJavaScript(jsCode)
  }
}

ここでGoogleログインで必要なSDKはダウンロードしておきましょう。

GoogleLoginのSDKダウンロード方法

  1. タブからFile->Swift Packages->Add Package Dependencyを選択
  2. https://github.com/google/GoogleSignIn-iOS を入力
  3. 表示されるパッケージをダウンロード

また、GoogleService-Info.plistにもClient-IDを入力しておく必要があるので、GCP側で作成したものを入力しておきましょう

GoogleService-Info.plist
	<key>CLIENT_ID</key>
	<string>xxx-xxxx.apps.googleusercontent.com</string>
	<key>REVERSED_CLIENT_ID</key>
	<string>com.googleusercontent.apps.xxxx-xxx</string>

C. 認証結果をWebに返す

上記ですでに実装しているのですが、

SocialAuth.swift
  private func sendToJavaScript(
    accessToken: String, userId: String, name: String, email: String, provider: String
  ) {
    let data = [
      "accessToken": accessToken,
      "userId": userId,
      "name": name,
      "email": email,
      "provider": provider,
    ]
    guard let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []) else {
      print("Error: could not create JSON data")
      return
    }
    let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
    let jsCode =
      "this.dispatchEvent(new CustomEvent('external-social-auth', { detail: \(jsonString) }))"
    yourProject.webView.evaluateJavaScript(jsCode)
  }
}

sendToJavaScriptでWeb側は'external-social-auth'というEvent名で受け取ることができます。

[結論] 対応方法(Web側の実装)

最後にWeb側の実装です。Next.js, Typescriptでの実装方法を示します。

A. アプリ側への認証依頼
B. アプリ側からのイベント受け取り
の2点です。

A. アプリ側への認証依頼

以下のような記述をすると任意のメッセージをアプリ側に渡せます。iOSアプリ側で押されたものか、そうでないかをisIOSAppの変数で分岐させています。(navigator.userAgentから得た情報で判別していますが一旦割愛)

Login.ts
 <Button
  variant="google"
  onClick={() => {
    if (isIOSApp) {
      // @ts-ignore
+      window.webkit.messageHandlers.socialAuth.postMessage(
+        "google"
      );
    } else {
      SignIn("google", redirectURL);
    }
  }}> ... </Button>
window.webkit.messageHandlers.
socialAuth. <- message.nameにあたる部分
postMessage
("google"); <- message.bodyにあたる部分

B. アプリ側からのイベント受け取り

EventListenerでアプリ側で実装した"external-social-auth"のイベントを受け取れるようにしましょう。

Login.ts
  useEffect(() => {
    const handleExternalSocialAuth = async (event: Event) => {
      const socialAuthEvent = event as SocialAuthEvent;
      const { accessToken, userId, name, email, provider } =
        socialAuthEvent.detail;
      try {
        const result = await signIn("credentials", {
          redirect: false,
          userId,
          name,
          email,
          accessToken,
          provider,
          providerAccountId: userId,
          callbackUrl: redirectURL,
        });
        if (result?.error) {
          console.error("Authentication error:", result.error);
        } else {
          console.log("Authentication successful");
        }
      } catch (error) {
        console.error("Error during authentication:", error);
      }
    };

    window.addEventListener(
      "external-social-auth",
      handleExternalSocialAuth as EventListener
    );

    return () => {
      window.removeEventListener(
        "external-social-auth",
        handleExternalSocialAuth as EventListener
      );
    };
  }, []);

ここでは情報を受け取った後にWeb側で再度Auth.jsのSignInを実行するという流れを取っています。SignInはCredential認証になるので、auth.config.tsも更新します。

auth.config.ts
export const nextAuthOptions: NextAuthConfig = {
  providers: [
+    CredentialsProvider({
+      name: "Credentials",
+      credentials: {
+        userId: { label: "User ID", type: "text" },
+        name: { label: "Name", type: "text" },
+        email: { label: "Email", type: "text" },
+        accessToken: { label: "Access Token", type: "text" },
+        provider: { label: "Provider", type: "text" },
+        providerAccountId: { label: "Provider Account ID", type: "text" },
+      },
+      async authorize(credentials): Promise<any> {
+        if (!credentials) return null;
+        try {
+          // ここでは既に検証済みのクレデンシャルを受け取るため、シンプルにユーザー情報を返すだけ
+          const user = {
+            id: credentials.userId,
+            name: credentials.name,
+            email: credentials.email,
+            provider: credentials.provider,
+          };
+          return user;
+        } catch (error) {
+          console.error("Authorization error:", error);
+          return null;
+        }
+      },
+    }),
...

これでログインボタンが押される→アプリ側で認証ポップアップが表示→ログイン成功後に任意の画面に遷移するが実現可能です。

終わりに

認証さえクリアしてしまえば、あとは基本的にWebの動作をアプリに置き換えられるのでかなり快適にマルチプラットフォームのサービスを単一コードで生成できるという点はPWAの利点だなと改めて感じました。

また、アプリのWebViewとWeb側の通信ロジックも一度理解してしまえば、ネイティブアプリ特有の実装(例えばSubscriptionやPush通知など)も少しイメージがつきやすくなるのではないでしょうか。

このあたりは私自身がまだ触れていない部分なので、機会があれば是非触って記事にできたらと考えています。

Discussion