diff --git a/Alarmify/API/AlarmifyAPIClient.swift b/Alarmify/API/AlarmifyAPIClient.swift index 372776c..46b36c6 100644 --- a/Alarmify/API/AlarmifyAPIClient.swift +++ b/Alarmify/API/AlarmifyAPIClient.swift @@ -17,6 +17,9 @@ protocol AlarmifyAPIClient: Sendable { func reportAlarmApply(_ report: AlarmApplyReport) async throws /// 呼び出し元自身のアカウントとサーバー上のデータ (API トークン・配送先・アラーム履歴) を削除する func deleteAccount() async throws + /// 匿名アカウントの端末を呼び出し元 (Sign in with Apple のアカウント) へ移し、匿名アカウントを削除する。 + /// `anonymousIDToken` は統合元の匿名アカウントの Firebase ID トークン + func mergeAnonymousAccount(anonymousIDToken: String) async throws } /// URLSession で Cloud Functions のアプリ向け API (`appApi`) を叩く実装。 @@ -119,6 +122,11 @@ struct URLSessionAlarmifyAPIClient: AlarmifyAPIClient { _ = try decode(CallableResponse.self, from: data).result } + /// 応答の本文 (移した端末の数) は画面に出さないため読まない + func mergeAnonymousAccount(anonymousIDToken: String) async throws { + _ = try await send(method: "POST", path: "/v1/account/merge", body: ["anonymous_id_token": anonymousIDToken]) + } + /// Callable 関数の成功応答 private struct CallableResponse: Decodable { let result: Result diff --git a/Alarmify/API/AlarmifyAPIError.swift b/Alarmify/API/AlarmifyAPIError.swift index 3748a24..d65bb13 100644 --- a/Alarmify/API/AlarmifyAPIError.swift +++ b/Alarmify/API/AlarmifyAPIError.swift @@ -36,6 +36,19 @@ enum AlarmifyAPIError: Error, Equatable, LocalizedError { return false } + /// 匿名アカウントの統合 (`POST /v1/account/merge`) をサーバーが受け付けない応答のコード。 + /// 統合元の ID トークンが期限切れ・匿名でない (`invalid_anonymous_id_token`)、統合先が匿名 (`merge_target_anonymous`)、 + /// 統合先が削除処理中 (`account_deleted`)、内容がスキーマに合わない (`invalid_argument`) + static let anonymousAccountMergeRejectedCodes: Set = ["invalid_anonymous_id_token", "merge_target_anonymous", "account_deleted", "invalid_argument"] + + /// 匿名アカウントの統合を送り直しても受け付けられない応答かどうか。送り直しを止めてよい判定に使う + var rejectsAnonymousAccountMerge: Bool { + if case .server(_, let code?, _) = self { + return Self.anonymousAccountMergeRejectedCodes.contains(code) + } + return false + } + var errorDescription: String? { switch self { case .notSignedIn: diff --git a/Alarmify/API/StubAlarmifyAPIClient.swift b/Alarmify/API/StubAlarmifyAPIClient.swift index cb412da..b742224 100644 --- a/Alarmify/API/StubAlarmifyAPIClient.swift +++ b/Alarmify/API/StubAlarmifyAPIClient.swift @@ -83,4 +83,7 @@ actor StubAlarmifyAPIClient: AlarmifyAPIClient { registeredFCMRegistrationToken = nil reports.removeAll() } + + /// スタブは Firebase Auth の実アカウントを切り替えないため、統合するデータも無い + func mergeAnonymousAccount(anonymousIDToken: String) async throws {} } diff --git a/Alarmify/Account/AccountSession.swift b/Alarmify/Account/AccountSession.swift index bcb5f45..64c3068 100644 --- a/Alarmify/Account/AccountSession.swift +++ b/Alarmify/Account/AccountSession.swift @@ -1,8 +1,11 @@ +import AuthenticationServices +import CryptoKit import FirebaseAppCheck import FirebaseAuth import Foundation import Observation import os +import UIKit /// この端末の配送先 (FCM 登録トークン) をバックエンドへ登録した状態 enum DeviceRegistrationState: Equatable, Sendable { @@ -24,15 +27,49 @@ enum PurchaseLinkState: Equatable, Sendable { case emulatorBackend } -/// 匿名認証で自動作成したアカウントと、この端末の配送先登録をまとめて持つ。 +/// Sign in with Apple の結果から Firebase Auth へ渡す値を取り出せなかった理由 +enum AppleSignInError: LocalizedError { + /// Apple の認証情報に identity token が含まれていない + case missingIdentityToken + /// Apple の認証情報に authorization code が含まれていない (トークンの失効に使う) + case missingAuthorizationCode + /// ボタンの結果が届いた時に、リクエストへ設定した nonce が残っていない + case missingNonce + /// Sign in with Apple かアカウント削除の処理中で、もう片方を始められない + case accountOperationInProgress + /// Apple のシートがキャンセル以外のエラーで終わった (端末が Apple アカウントにサインインしていない等) + case authorizationFailed + + var errorDescription: String? { + switch self { + case .missingIdentityToken, .missingAuthorizationCode, .missingNonce, .authorizationFailed: + // ja: Apple でのサインインを完了できませんでした + String(localized: "Couldn't complete Sign in with Apple") + case .accountOperationInProgress: + // ja: 処理中の操作が終わってからやり直してください + String(localized: "Try again after the current operation finishes") + } + } +} + +/// 匿名認証で自動作成したアカウント (Sign in with Apple でリンク・統合したものを含む) と、この端末の配送先登録をまとめて持つ。 /// FCM トークンの受信は `AppDelegate`、表示は SwiftUI と入口が分かれるため 1 インスタンス (`shared`) に集約する @MainActor @Observable final class AccountSession { static let shared = AccountSession() - /// 匿名認証で作られたアカウントの uid。未サインインなら nil + /// サインイン中のアカウントの uid (匿名、または Sign in with Apple のアカウント)。未サインインなら nil private(set) var uid: String? + /// サインイン中のアカウントに Apple の認証情報がリンクされているか。 + /// リンク済みなら別の iPhone から同じ Apple アカウントでサインインして、同じ uid (API トークン・登録端末) を使える + private(set) var appleIDLinked = false + /// Sign in with Apple の処理中か。ボタンの二重タップを防ぎ、進行中の表示に使う + private(set) var appleSignInInProgress = false + /// アカウント削除の処理中か。削除と Sign in with Apple はどちらもサインイン中のユーザーを変えるため、片方の処理中はもう片方を始めない + private(set) var accountDeletionInProgress = false + /// Sign in with Apple (匿名アカウントの統合を含む) に失敗したエラーの説明。成功したら nil に戻す + private(set) var appleSignInError: String? /// FCM の登録トークン。simulator でも取得できるが、実際の配送には APNs キーの登録が要る private(set) var fcmRegistrationToken: String? private(set) var deviceRegistration: DeviceRegistrationState = .notRegistered @@ -49,6 +86,15 @@ final class AccountSession { private var apiClient: AlarmifyAPIClient /// 実行中・実行待ちの端末登録。登録は同じ device_id を書き換えるため直列に行う private var registration: Task? + /// 表示中の Sign in with Apple のリクエストに設定した nonce の原文。Apple へはハッシュを渡し、Firebase Auth へは原文を渡して照合させる + private var appleIDRequestNonce: String? + /// 既存の Apple アカウントへ切り替えた後、まだバックエンドで統合できていない匿名アカウントの ID トークン。 + /// 切り替えた後は匿名アカウントの ID トークンを取り直せないため保持し、失敗したら次の signIn (起動・前面復帰) で送り直す。 + /// アプリが終了しても送り直せるよう keychain に保存する + private var pendingAnonymousMergeIDToken: String? { + get { PendingAnonymousMergeIDTokenStore.load() } + set { PendingAnonymousMergeIDTokenStore.save(newValue) } + } /// 既定は保存済みの開発者設定から作る。テストは設定を直接渡して UserDefaults に触れずに組み立てる init(settings: DeveloperSettings = DeveloperMenu.settings) { @@ -65,8 +111,11 @@ final class AccountSession { DeveloperMenu.authenticatedBackend = settings.backend } uid = user.uid + appleIDLinked = Self.hasAppleID(user: user) signInError = nil + await mergePendingAnonymousAccount() await registerDeviceAndLinkPurchases(uid: user.uid) + await syncPendingPurchases(uid: user.uid) return } let signedInUid: String @@ -75,6 +124,7 @@ final class AccountSession { DeveloperMenu.authenticatedBackend = settings.backend signedInUid = result.user.uid uid = signedInUid + appleIDLinked = false signInError = nil } catch { signInError = error.localizedDescription @@ -84,6 +134,165 @@ final class AccountSession { await registerDeviceAndLinkPurchases(uid: signedInUid) } + // MARK: - Sign in with Apple + + /// Sign in with Apple のボタンが作るリクエストに nonce を設定する。 + /// nonce は identity token の使い回しを防ぐため Firebase Auth が照合する値で、リクエストのたびに作り直す + func prepare(appleIDRequest: ASAuthorizationAppleIDRequest) { + let nonce = Self.makeNonce() + appleIDRequestNonce = nonce + // メールアドレスと氏名は使わないため要求しない (Firebase Auth に保持させず、App Privacy の回答を増やさない。documents/app-privacy.md) + appleIDRequest.requestedScopes = [] + appleIDRequest.nonce = Self.sha256(nonce: nonce) + } + + /// Sign in with Apple のボタンの結果でサインインする。 + /// 匿名アカウントに Apple の認証情報をリンクして uid を維持し、その Apple アカウントが既に別の uid で使われていれば + /// Apple 側の uid へ切り替えて、匿名側の端末をバックエンドで移す (API トークンと当月の利用数は Apple 側のものを使う)。 + /// ユーザーがシートを閉じた時は何もしない + func completeSignInWithApple(result: Result) async { + let nonce = appleIDRequestNonce + appleIDRequestNonce = nil + switch result { + case .failure(let error): + if (error as? ASAuthorizationError)?.code != .canceled { + // AuthenticationServices のエラーの説明は「com.apple.AuthenticationServices.AuthorizationError error 1000」のような + // 内部表現になる (端末が Apple アカウントにサインインしていないまま閉じた時等) ため、画面には一般的な文言を出して原文はログに残す + appleSignInError = AppleSignInError.authorizationFailed.localizedDescription + Logger.account.error("Sign in with Apple failed: \(error.localizedDescription)") + } + case .success(let authorization): + // 削除の途中でサインインを切り替えると、削除後の signOut が切り替え先をサインアウトしてしまう + guard !accountDeletionInProgress, !appleSignInInProgress else { return } + appleSignInInProgress = true + defer { appleSignInInProgress = false } + do { + guard let nonce else { throw AppleSignInError.missingNonce } + try await signInWithApple(authorization: authorization, rawNonce: nonce) + // 統合の送信に失敗した時は mergePendingAnonymousAccount が設定したエラーを残す + if pendingAnonymousMergeIDToken == nil { + appleSignInError = nil + } + } catch { + appleSignInError = error.localizedDescription + Logger.account.error("Sign in with Apple failed: \(error.localizedDescription)") + } + } + } + + /// Apple の認証情報を Firebase Auth の認証情報にして、リンク・既存アカウントへの切り替え・そのままのサインインのどれかを行う + private func signInWithApple(authorization: ASAuthorization, rawNonce: String) async throws { + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, + let identityToken = appleIDCredential.identityToken.flatMap({ String(data: $0, encoding: .utf8) }) + else { throw AppleSignInError.missingIdentityToken } + let credential = OAuthProvider.appleCredential(withIDToken: identityToken, rawNonce: rawNonce, fullName: nil) + guard let currentUser = Auth.auth().currentUser else { + // 匿名認証に失敗したまま (オフラインで起動した等) の状態。統合する匿名アカウントが無いため Apple のアカウントでそのままサインインする + let result = try await Auth.auth().signIn(with: credential) + DeveloperMenu.authenticatedBackend = settings.backend + await switchAccount(uid: result.user.uid) + return + } + do { + _ = try await currentUser.link(with: credential) + appleIDLinked = true + } catch let error as NSError + where currentUser.isAnonymous + && error.domain == AuthErrorDomain + && error.code == AuthErrorCode.credentialAlreadyInUse.rawValue + { + // この Apple アカウントは別の uid で使われている。identity token は 1 度しか使えないため、Firebase Auth がエラーに添える認証情報でサインインする + guard let existingAccountCredential = error.userInfo[AuthErrors.userInfoUpdatedCredentialKey] as? AuthCredential else { throw error } + // サインインを切り替えると匿名アカウントの ID トークンを取り直せないため、先に取っておく + // キャッシュ済みのトークンは期限が近いことがあるため発行し直し、送り直しに使える期間 (1 時間) を最大にする + pendingAnonymousMergeIDToken = try await currentUser.idTokenForcingRefresh(true) + let result = try await Auth.auth().signIn(with: existingAccountCredential) + await switchAccount(uid: result.user.uid) + } + } + + /// Apple のアカウントへ切り替えた後に、統合・端末登録・購入の結び付けをやり直す。 + /// 購入は RevenueCat の logIn だけでは元の App User ID (匿名アカウントの uid) に残るため、StoreKit の購入を送り直して + /// プロジェクトの restore behavior (既定の Transfer to new App User ID) で Apple 側の uid へ移す + /// ( https://www.revenuecat.com/docs/projects/restore-behavior )。移った購入は webhook の TRANSFER が users/{uid}.plan に反映する + private func switchAccount(uid newUid: String) async { + uid = newUid + appleIDLinked = true + signInError = nil + deviceRegistration = .notRegistered + UserDefaults.standard.set(newUid, forKey: .pendingPurchaseSyncAppUserID) + await mergePendingAnonymousAccount() + await registerDeviceAndLinkPurchases(uid: newUid) + await syncPendingPurchases(uid: newUid) + } + + /// アカウントを切り替えた後の購入の送り直しが済んでいなければ行う。送り直せたら記録を消し、失敗したら次の signIn (起動・前面復帰) でやり直す。 + /// RevenueCat の logIn は失敗を内部で握りつぶすため、今の App User ID がこの uid になったことを確かめてから送る + /// (前の uid のまま送ると、購入が切り替え先へ移らない)。何度呼んでも、送り直しが済んだ状態に収束する + private func syncPendingPurchases(uid: String) async { + guard UserDefaults.standard.string(forKey: .pendingPurchaseSyncAppUserID) == uid else { return } + // エミュレータ向けのアカウントは RevenueCat に結び付けない (linkPurchases) ため、送り直す先が無い + guard settings.backend == .production else { + UserDefaults.standard.removeObject(forKey: .pendingPurchaseSyncAppUserID) + return + } + guard ProEntitlement.isLoggedIn(as: uid), await ProEntitlement.syncPurchases() else { return } + UserDefaults.standard.removeObject(forKey: .pendingPurchaseSyncAppUserID) + } + + /// 切り替える前の匿名アカウントの端末を Apple 側へ移し、匿名アカウントをバックエンドで削除する。 + /// 送れたか、送り直しても受け付けられない (ID トークンの期限切れ等) ならトークンを捨て、通信エラー等は次の signIn で送り直す。 + /// サーバー側が冪等なため、何度呼んでも同じ状態になる + private func mergePendingAnonymousAccount() async { + guard let pendingAnonymousMergeIDToken else { return } + // Apple 側へのサインインの切り替えを待っている間 (前面復帰の signIn が割り込んだ時) はまだ匿名のため送らない。 + // 送るとサーバーが統合先の匿名を拒否し、トークンを捨てて統合をやり直せなくなる。切り替えが終わった後の switchAccount が送る + guard let currentUser = Auth.auth().currentUser, !currentUser.isAnonymous else { return } + do { + try await apiClient.mergeAnonymousAccount(anonymousIDToken: pendingAnonymousMergeIDToken) + self.pendingAnonymousMergeIDToken = nil + appleSignInError = nil + } catch let error as AlarmifyAPIError where error.rejectsAnonymousAccountMerge { + self.pendingAnonymousMergeIDToken = nil + Logger.account.error("Merging the anonymous account was rejected: \(error.localizedDescription)") + } catch { + // ja: この iPhone の端末情報の移行が完了していません。アプリを開き直すと再試行します + appleSignInError = String(localized: "Moving this iPhone to your Apple account isn't finished yet. It retries when you reopen the app.") + Logger.account.error("Merging the anonymous account failed: \(error.localizedDescription)") + } + } + + /// Apple のトークンを失効させる (App Store Review Guideline 5.1.1 (v) の Sign in with Apple を使うアカウントの削除)。 + /// 失効に使う authorization code は発行から 5 分しか使えないため、削除のたびに Sign in with Apple をやり直して受け取る。 + /// 交換と失効は Firebase Auth の Apple プロバイダに設定した Team ID / Key ID / 秘密鍵で Firebase が行う + private func revokeAppleToken() async throws { + // Firebase の revokeToken はサインイン中のユーザーが居ないと完了を呼ばずに終わる (async 版が戻らない) ため、先に確かめる + guard Auth.auth().currentUser != nil else { throw AlarmifyAPIError.notSignedIn } + let appleIDCredential = try await AppleIDAuthorization().perform() + guard let authorizationCode = appleIDCredential.authorizationCode.flatMap({ String(data: $0, encoding: .utf8) }) else { + throw AppleSignInError.missingAuthorizationCode + } + try await Auth.auth().revokeToken(withAuthorizationCode: authorizationCode) + } + + /// Firebase Auth のユーザーに Apple の認証情報がリンクされているか + private static func hasAppleID(user: User) -> Bool { + user.providerData.contains { $0.providerID == AuthProviderID.apple.rawValue } + } + + /// Apple が推奨する nonce の作り方 (暗号論的な乱数を 32 バイト)。Firebase のドキュメントの Sign in with Apple の例と同じ長さにする + private static func makeNonce() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + precondition(status == errSecSuccess, "SecRandomCopyBytes failed: \(status)") + return bytes.map { String(format: "%02x", $0) }.joined() + } + + /// リクエストに設定する nonce のハッシュ (SHA-256 の 16 進文字列)。Apple は受け取った値を identity token の nonce にそのまま入れる + private static func sha256(nonce: String) -> String { + SHA256.hash(data: Data(nonce.utf8)).map { String(format: "%02x", $0) }.joined() + } + /// 配送先の登録と RevenueCat の identity 連携を並行して行う。 /// RevenueCat の応答を待つ間に配送先の登録 (サインインの完了前に届いていた FCM トークンの登録を含む) を遅らせない。 /// サインインが済んだこのタイミングで、Extension や前回の起動が積んだ適用結果の報告も送る @@ -175,8 +384,22 @@ final class AccountSession { /// アカウントとサーバー上のデータを削除し、アプリを初回起動と同じ状態 (新しい匿名アカウント) に戻す。 /// 端末内の AlarmKit のアラームには触れない (公開している削除手順の記載と揃える)。 - /// サーバーの削除に失敗した場合はサインイン状態を変えずにエラーを投げる (削除できていないアカウントを画面から消さない) + /// サーバーの削除に失敗した場合はサインイン状態を変えずにエラーを投げる (削除できていないアカウントを画面から消さない)。 + /// Apple の認証情報がリンクされていれば、先に Apple のトークンを失効させる。失効できなければ削除に進まずエラーを投げる + /// (失効させないまま Firebase のユーザーを消すと、失効させる手段が残らない)。ユーザーが Apple のシートを閉じた時も同じく中断する func deleteAccount() async throws { + // Sign in with Apple の途中で削除すると、確認した時とは別のアカウントを消し得る + guard !appleSignInInProgress, !accountDeletionInProgress else { throw AppleSignInError.accountOperationInProgress } + accountDeletionInProgress = true + defer { accountDeletionInProgress = false } + // スタブは Firebase Auth の実アカウントに触れないため、Apple のトークンも失効させない + if appleIDLinked, !settings.stubAPIClient { + do { + try await revokeAppleToken() + } catch where Self.isAccountAlreadyGone(error) { + // 前回の削除がサーバーで成功して応答だけ失われた後の再試行。失効は削除より先に済ませているため、端末側の状態を揃える処理へ進む + } + } do { try await apiClient.deleteAccount() } catch where Self.isAccountAlreadyGone(error) { @@ -189,6 +412,10 @@ final class AccountSession { try Auth.auth().signOut() DeveloperMenu.authenticatedBackend = nil uid = nil + appleIDLinked = false + // 削除したアカウントへの切り替えの後処理は、送っても意味が無いため残さない + pendingAnonymousMergeIDToken = nil + UserDefaults.standard.removeObject(forKey: .pendingPurchaseSyncAppUserID) deviceRegistration = .notRegistered await signIn() } @@ -251,3 +478,99 @@ final class AccountSession { ) } } + +/// ボタンを介さずに Sign in with Apple のシートを出し、Apple の認証情報を async で受け取る 1 回分のリクエスト。 +/// ASAuthorizationController は結果を delegate で返すため class にする。呼び出し側の `perform()` の await が +/// このインスタンスを保持し、インスタンスがコントローラーを保持するため、結果が届くまで解放されない +@MainActor +private final class AppleIDAuthorization: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { + /// 表示中のコントローラー。delegate の呼び出しが終わるまで保持する + private var controller: ASAuthorizationController? + /// `perform()` の呼び出し元へ結果を返す continuation + private var continuation: CheckedContinuation? + + /// Sign in with Apple のシートを出し、ユーザーが認証するかシートを閉じるまで待つ + func perform() async throws -> ASAuthorizationAppleIDCredential { + let request = ASAuthorizationAppleIDProvider().createRequest() + // トークンの失効に使う authorization code だけが要るため、メールアドレスと氏名は要求しない + request.requestedScopes = [] + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + self.controller = controller + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + controller.performRequests() + } + } + + /// Apple ID 以外の認証情報 (パスワード等) は要求していないため届かないが、届いた時も continuation を再開して呼び出し元を待たせない + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential { + finish(result: .success(appleIDCredential)) + } else { + finish(result: .failure(AppleSignInError.missingAuthorizationCode)) + } + } + + /// シートを閉じた時は ASAuthorizationError.canceled が届き、呼び出し元が削除の中断として扱う + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + finish(result: .failure(error)) + } + + /// シートを出す先。前面のウィンドウを使う + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + let windowScenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + if let keyWindow = windowScenes.flatMap(\.windows).first(where: \.isKeyWindow) { + return keyWindow + } + // 設定画面の操作から呼ぶため、画面を持つシーンは必ずある + guard let windowScene = windowScenes.first else { preconditionFailure("No window scene to present Sign in with Apple") } + return ASPresentationAnchor(windowScene: windowScene) + } + + /// continuation は 1 度しか再開できないため、取り出してから再開する + private func finish(result: Result) { + continuation?.resume(with: result) + continuation = nil + controller = nil + } +} + +/// 未完了の匿名アカウントの統合に使う ID トークンの保存先 (keychain)。 +/// ID トークンは有効期間 (1 時間) の間は匿名アカウントへのアクセス権になるため、UserDefaults ではなく keychain に置き、この端末からだけ読めるようにする +enum PendingAnonymousMergeIDTokenStore { + /// keychain の項目を識別する kSecAttrService。bundle id を接頭辞にして他の項目と衝突させない + static let service = "com.bannzai.Alarmify.pendingAnonymousMergeIDToken" + + /// 保存済みの ID トークン。無い・読めない時は nil + static func load() -> String? { + var item: CFTypeRef? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + /// ID トークンを保存する。nil なら消す。既存の項目を消してから追加するため、何度呼んでも最後の値だけが残る + static func save(_ idToken: String?) { + let baseQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + SecItemDelete(baseQuery as CFDictionary) + guard let idToken else { return } + var attributes = baseQuery + attributes[kSecValueData as String] = Data(idToken.utf8) + // 起動直後の前面復帰 (ロック解除後) で読めればよく、バックアップや他の端末へ移す必要は無い + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(attributes as CFDictionary, nil) + if status != errSecSuccess { + Logger.account.error("Saving the pending anonymous merge token failed: \(status)") + } + } +} diff --git a/Alarmify/Alarmify.entitlements b/Alarmify/Alarmify.entitlements index 357c71f..8f5956e 100644 --- a/Alarmify/Alarmify.entitlements +++ b/Alarmify/Alarmify.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.applesignin + + Default + com.apple.developer.devicecheck.appattest-environment diff --git a/Alarmify/Features/Purchase/ProEntitlement.swift b/Alarmify/Features/Purchase/ProEntitlement.swift index c123434..2dd0c6c 100644 --- a/Alarmify/Features/Purchase/ProEntitlement.swift +++ b/Alarmify/Features/Purchase/ProEntitlement.swift @@ -10,6 +10,9 @@ extension String { /// 購読はアプリ停止中に失効し得るため、proEntitlementActive と対で保存して参照時に同期判定する。 /// 請求猶予期間中はその終了日時 (RevenueCat の SubscriptionInfo.gracePeriodExpiresDate) を保存する static let proEntitlementExpiration = "proEntitlementExpiration" + /// 既存の Apple アカウントへ切り替えた後、購入を RevenueCat へ送り直す必要がある App User ID (切り替え先の uid)。 + /// 送り直せるまで残し、アプリが終了しても次の起動でやり直せるよう UserDefaults に保存する (`AccountSession.syncPendingPurchases`) + static let pendingPurchaseSyncAppUserID = "pendingPurchaseSyncAppUserID" } /// キャッシュへ保存する実効的な失効日時。 @@ -109,6 +112,22 @@ enum ProEntitlement { Purchases.isConfigured && Purchases.shared.appUserID == appUserID } + /// この端末の StoreKit の購入を、今の App User ID で RevenueCat へ送り直す。 + /// 別の App User ID に結び付いている購入は、プロジェクトの restore behavior に従って今の App User ID へ移る + /// (既定の Transfer to new App User ID の場合。 https://www.revenuecat.com/docs/projects/restore-behavior )。 + /// OS のサインインを求めない (restorePurchases と違い Apple ID の入力を促さない)。何度呼んでも同じ状態になる。 + /// 送り直せたかを返す (未 configure では送れないため false) + static func syncPurchases() async -> Bool { + guard Purchases.isConfigured else { return false } + do { + cacheEntitlement(customerInfo: try await Purchases.shared.syncPurchases()) + return true + } catch { + Logger.purchase.error("RevenueCat syncPurchases failed: \(error.localizedDescription)") + return false + } + } + /// RevenueCat の identity を匿名 ID に戻す。既に匿名なら何もしない (冪等。匿名の logOut は SDK がエラーにする) static func logOut() async { guard Purchases.isConfigured, !Purchases.shared.isAnonymous else { return } diff --git a/Alarmify/Localizable.xcstrings b/Alarmify/Localizable.xcstrings index e7acf80..caeec7d 100644 --- a/Alarmify/Localizable.xcstrings +++ b/Alarmify/Localizable.xcstrings @@ -3980,6 +3980,16 @@ } } }, + "Apple Account" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple アカウント" + } + } + } + }, "Apply" : { "localizations" : { "ar" : { @@ -6852,6 +6862,16 @@ } } }, + "Couldn't complete Sign in with Apple" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple でのサインインを完了できませんでした" + } + } + } + }, "Couldn't link purchases to your account. Check your connection and try again." : { "localizations" : { "ar" : { @@ -12188,6 +12208,16 @@ } } }, + "Moving this iPhone to your Apple account isn't finished yet. It retries when you reopen the app." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この iPhone の端末情報の移行が完了していません。アプリを開き直すと再試行します" + } + } + } + }, "New token" : { "localizations" : { "ja" : { @@ -21134,6 +21164,16 @@ } } }, + "Sign in to use the same API token on multiple iPhones or to move to a new iPhone" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "複数の iPhone で同じ API トークンを使う時や機種変更で引き継ぐ時にサインインします" + } + } + } + }, "Signalarm" : { "shouldTranslate" : false }, @@ -21157,6 +21197,16 @@ } } }, + "Signed in" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サインイン済み" + } + } + } + }, "Signing in" : { "localizations" : { "ar" : { @@ -24283,6 +24333,16 @@ } } }, + "Try again after the current operation finishes" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "処理中の操作が終わってからやり直してください" + } + } + } + }, "Try issuing again" : { "localizations" : { "ja" : { diff --git a/Alarmify/Settings/SettingsView.swift b/Alarmify/Settings/SettingsView.swift index df659ab..1997be7 100644 --- a/Alarmify/Settings/SettingsView.swift +++ b/Alarmify/Settings/SettingsView.swift @@ -1,4 +1,5 @@ import AlarmKit +import AuthenticationServices import LicenseList import SwiftUI import UserNotifications @@ -31,6 +32,8 @@ struct SettingsView: View { /// バックグラウンドから戻った時に now と権限の状態を取り直すための scene の状態 @Environment(\.scenePhase) private var scenePhase @Environment(\.openURL) private var openURL + /// Sign in with Apple のボタンの配色を背景と逆にするために読む (Apple の Human Interface Guidelines の推奨) + @Environment(\.colorScheme) private var colorScheme /// 削除の確認ダイアログの表示状態 @State private var deletionConfirmation = false @State private var alarmAuthorization = AlarmKitScheduler.authorizationState @@ -92,7 +95,7 @@ struct SettingsView: View { .padding(.horizontal, DesignMetrics.screenHorizontalPadding) .padding(.top, 14) .accessibilityIdentifier("settings_delete_account") - .disabled(session.uid == nil || deletionState == .deleting) + .disabled(session.uid == nil || deletionState == .deleting || session.appleSignInInProgress) if case .failed(let message) = deletionState { Text(message) @@ -299,6 +302,8 @@ struct SettingsView: View { .rowPadding() .accessibilityIdentifier("settings_account_id") HairlineDivider() + appleAccountRow + HairlineDivider() Link(destination: LegalLinks.supportMail(accountID: session.uid)) { HStack { // ja: サポート @@ -320,6 +325,59 @@ struct SettingsView: View { .padding(.horizontal, DesignMetrics.screenHorizontalPadding) } + /// Sign in with Apple の導線。サインインしなくても従来どおり使え、複数の iPhone で同じアカウントを使う時・機種変更で引き継ぐ時にだけサインインする + @ViewBuilder + private var appleAccountRow: some View { + if session.appleIDLinked { + HStack { + // ja: Apple アカウント + Text("Apple Account") + .font(.body) + .foregroundStyle(Color.paper) + Spacer() + // ja: サインイン済み + Text("Signed in") + .font(.body) + .foregroundStyle(Color.paperTertiary) + } + .rowPadding() + .accessibilityIdentifier("settings_apple_account") + } else { + VStack(alignment: .leading, spacing: 10) { + SignInWithAppleButton(.signIn) { request in + session.prepare(appleIDRequest: request) + } onCompletion: { result in + Task { await session.completeSignInWithApple(result: result) } + } + .signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black) + // SignInWithAppleButton は表示した後にスタイルが変わっても描き直さない (simtunnel で外観を切り替えて確認) ため、外観ごとに作り直す + .id(colorScheme) + .frame(height: 44) + .disabled(session.uid == nil || session.appleSignInInProgress || session.accountDeletionInProgress) + .accessibilityIdentifier("settings_sign_in_with_apple") + // ja: 複数の iPhone で同じ API トークンを使う時や機種変更で引き継ぐ時にサインインします + Text("Sign in to use the same API token on multiple iPhones or to move to a new iPhone") + .font(.footnote) + .foregroundStyle(Color.paperTertiary) + } + .rowPadding() + } + if session.appleSignInInProgress { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.bottom, 12) + } + if let appleSignInError = session.appleSignInError { + Text(appleSignInError) + .font(.footnote) + .foregroundStyle(Color.destructive) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, DesignMetrics.textHorizontalPadding) + .padding(.bottom, 12) + .accessibilityIdentifier("settings_apple_sign_in_error") + } + } + private var legalCard: some View { VStack(spacing: 0) { // ja: 利用規約 @@ -473,6 +531,9 @@ struct SettingsView: View { do { try await session.deleteAccount() deletionState = .deleted + } catch let error as ASAuthorizationError where error.code == .canceled { + // Apple のトークンの失効に必要なサインインのシートを閉じた。削除をやめたものとして扱う + deletionState = .idle } catch { deletionState = .failed(message: error.localizedDescription) } diff --git a/Alarmify/Shared/Log.swift b/Alarmify/Shared/Log.swift index 83a332d..4dc7ecc 100644 --- a/Alarmify/Shared/Log.swift +++ b/Alarmify/Shared/Log.swift @@ -9,6 +9,9 @@ extension Logger { /// App Check のトークン取得に関するログ。取得できなくてもリクエストは送るため、失敗はここにだけ残る static let appCheck = Logger(subsystem: subsystem, category: "appCheck") + /// Sign in with Apple のリンク・アカウントの統合・トークンの失効に関するログ + static let account = Logger(subsystem: subsystem, category: "account") + /// RevenueCat の identity 連携 (logIn) と entitlement の反映に関するログ static let purchase = Logger(subsystem: subsystem, category: "purchase") } diff --git a/AlarmifyTests/AccountDeletionTests.swift b/AlarmifyTests/AccountDeletionTests.swift index 6859de6..4913c7f 100644 --- a/AlarmifyTests/AccountDeletionTests.swift +++ b/AlarmifyTests/AccountDeletionTests.swift @@ -94,6 +94,19 @@ final class AccountDeletionTests: XCTestCase { XCTAssertTrue(tokens.isEmpty) } + /// 未完了の匿名アカウントの統合は、アプリが終了しても次の起動で送り直せるよう keychain に残り、nil で消える + func testPendingAnonymousMergeIDTokenSurvivesUntilCleared() { + PendingAnonymousMergeIDTokenStore.save(nil) + XCTAssertNil(PendingAnonymousMergeIDTokenStore.load()) + + PendingAnonymousMergeIDTokenStore.save("anonymous-id-token-1") + PendingAnonymousMergeIDTokenStore.save("anonymous-id-token-2") + XCTAssertEqual(PendingAnonymousMergeIDTokenStore.load(), "anonymous-id-token-2") + + PendingAnonymousMergeIDTokenStore.save(nil) + XCTAssertNil(PendingAnonymousMergeIDTokenStore.load()) + } + /// 削除手順のページは ja 版と en 版しか公開していないため、日本語以外の表示言語では英語版へ寄せる /// (Localizable.xcstrings で言語別に URL を持つと、翻訳した言語ぶんの存在しないページへのリンクになる。PR #43 の Codex 指摘) func testAccountDeletionGuideFallsBackToEnglishForUnsupportedLanguages() { diff --git a/AlarmifyTests/AlarmifyAPIClientTests.swift b/AlarmifyTests/AlarmifyAPIClientTests.swift index e5a584a..3d8829b 100644 --- a/AlarmifyTests/AlarmifyAPIClientTests.swift +++ b/AlarmifyTests/AlarmifyAPIClientTests.swift @@ -120,6 +120,30 @@ final class AlarmifyAPIClientTests: XCTestCase { try await makeClient().registerDevice(fcmRegistrationToken: "fcm-token") } + /// 統合先は Authorization の ID トークン (Apple 側)、統合元は本文の匿名アカウントの ID トークンで伝える + func testMergeAnonymousAccountSendsTheAnonymousIDTokenInTheBody() async throws { + StubURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path(), "/demo-alarmify/asia-northeast1/appApi/v1/account/merge") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer id-token") + let body = (try? JSONSerialization.jsonObject(with: StubURLProtocol.body(of: request))) as? [String: String] + XCTAssertEqual(body, ["anonymous_id_token": "anonymous-id-token"]) + return (200, Data(#"{"moved_devices":1}"#.utf8)) + } + + try await makeClient().mergeAnonymousAccount(anonymousIDToken: "anonymous-id-token") + } + + /// 送り直しても通らない統合の拒否だけを止め、通信エラー等は次の起動で送り直せるよう区別する + func testRejectedAnonymousAccountMergeIsRecognizedFromTheErrorCode() { + for code in ["invalid_anonymous_id_token", "merge_target_anonymous", "account_deleted", "invalid_argument"] { + XCTAssertTrue(AlarmifyAPIError.server(statusCode: 400, code: code, message: "").rejectsAnonymousAccountMerge, code) + } + XCTAssertFalse(AlarmifyAPIError.server(statusCode: 404, code: "not_found", message: "").rejectsAnonymousAccountMerge) + XCTAssertFalse(AlarmifyAPIError.server(statusCode: 503, code: nil, message: "").rejectsAnonymousAccountMerge) + XCTAssertFalse(AlarmifyAPIError.notSignedIn.rejectsAnonymousAccountMerge) + } + func testRevokeUsesTheTokenIdInThePath() async throws { StubURLProtocol.handler = { request in XCTAssertEqual(request.httpMethod, "DELETE") diff --git a/documents/app-privacy.md b/documents/app-privacy.md index 39dd3db..cf1507b 100644 --- a/documents/app-privacy.md +++ b/documents/app-privacy.md @@ -15,13 +15,13 @@ App Store Connect の「App のプライバシー」への回答内容と、そ | category | purposes | data_protections | 実体 | 根拠 | |---|---|---|---|---| -| USER_ID (ユーザー ID) | APP_FUNCTIONALITY | DATA_LINKED_TO_YOU | Firebase Auth の匿名 uid、API トークン (ハッシュ) | アカウントと API トークンの認証に必須。uid に紐づくため「ユーザーに紐付く」 | +| USER_ID (ユーザー ID) | APP_FUNCTIONALITY | DATA_LINKED_TO_YOU | Firebase Auth の uid (匿名、または Sign in with Apple)、Sign in with Apple のユーザー識別子、API トークン (ハッシュ) | アカウントと API トークンの認証に必須。uid に紐づくため「ユーザーに紐付く」 | | DEVICE_ID (デバイス ID) | APP_FUNCTIONALITY | DATA_LINKED_TO_YOU | APNs デバイストークン (FCM 登録トークン)、端末種別・OS・アプリのバージョン、App Check トークン (App Attest) | push の配送先。`users/{uid}/devices` に保存するため「ユーザーに紐付く」 | | OTHER_USER_CONTENT (その他のユーザーコンテンツ) | APP_FUNCTIONALITY | DATA_LINKED_TO_YOU | 外部サービスから送られたアラームのタイトル・日時・送信元。30 日で削除 | 配送と履歴表示 (Pro) のためサーバーに保存する | | PURCHASE_HISTORY (購入履歴) | ANALYTICS, APP_FUNCTIONALITY | DATA_LINKED_TO_YOU | RevenueCat SDK が購入・購読情報を RevenueCat サーバーへ送信する。`Purchases.logIn` で Firebase Auth の uid を App User ID にしている (`Alarmify/Features/Purchase/ProEntitlement.swift`) | RevenueCat 公式は匿名 App User ID で個人を識別できない場合に DATA_NOT_LINKED_TO_YOU を選べると説明する ( https://www.revenuecat.com/docs/platform-resources/apple-platform-resources/apple-app-privacy ) だが、uid で識別できるアカウントに購入履歴が紐づくため「ユーザーに紐付く」。RevenueCat の webhook がこの uid で `users/{uid}.plan` を更新する (#19) | | OTHER_DIAGNOSTIC_DATA (その他の診断データ) | APP_FUNCTIONALITY | DATA_LINKED_TO_YOU | アラーム登録・取消の反映結果、失敗時のエラー、反映日時 | `AlarmApplyReportQueue` に保存した結果を `AlarmifyAPIClient.reportAlarmApply` が送信し、サーバーはユーザーのアラームに端末別の結果を保存する。履歴に登録の成否を表示するために用いる | -Sign in with Apple を実装した時点で、Apple から受け取るメールアドレス (中継アドレスを含む) を Firebase Auth が保持するため EMAIL_ADDRESS (APP_FUNCTIONALITY / DATA_LINKED_TO_YOU) を追加する。 +Sign in with Apple はメールアドレスと氏名のスコープを要求しない (`AccountSession.prepare(appleIDRequest:)` の `requestedScopes = []`)。Apple の identity token にメールアドレスが載らず Firebase Auth も保持しないため、EMAIL_ADDRESS と NAME は収集しない。Firebase Auth が保持する Apple のユーザー識別子は USER_ID に含める。スコープを要求するよう変えた時は EMAIL_ADDRESS (APP_FUNCTIONALITY / DATA_LINKED_TO_YOU) を追加する。 ## 収集しないデータ @@ -56,7 +56,7 @@ Widget Extension は ActivityKit から受け取った属性を表示するだ ## 回答の確認根拠 -2026-09-12 にソースと以下の公式資料を照合した。認証は `signInAnonymously` のみで、メールアドレスの収集はない。App Attest の attestation / assertion と App Check トークン、FCM の登録先識別子は既存の DEVICE_ID の説明に含める。Analytics SDK が無くても RevenueCat の購入履歴には ANALYTICS の回答が必要。 +2026-09-12 にソースと以下の公式資料を照合した。この時点の認証は `signInAnonymously` のみで、メールアドレスの収集はない。2026-09-23 (#106) に Sign in with Apple を加えた時に照合し直し、`AccountSession.prepare(appleIDRequest:)` がスコープを要求しない (`requestedScopes = []`) ため、Firebase Auth が保持するのは Apple のユーザー識別子だけで、メールアドレスと氏名は収集しないことを確かめた。App Attest の attestation / assertion と App Check トークン、FCM の登録先識別子は既存の DEVICE_ID の説明に含める。Analytics SDK が無くても RevenueCat の購入履歴には ANALYTICS の回答が必要。 - Apple の収集データ・利用目的の定義: https://developer.apple.com/app-store/app-privacy-details/ - Apple の UserDefaults 利用理由: https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype diff --git a/firebase/functions/src/account/deleteAccount.ts b/firebase/functions/src/account/deleteAccount.ts index 593da7b..09b0348 100644 --- a/firebase/functions/src/account/deleteAccount.ts +++ b/firebase/functions/src/account/deleteAccount.ts @@ -2,7 +2,7 @@ import type { Auth } from "firebase-admin/auth"; import { FieldValue, type DocumentReference, type Firestore, type Timestamp } from "firebase-admin/firestore"; import { HttpsError } from "firebase-functions/https"; import { logger } from "firebase-functions"; -import { collections, deletedAccountFields } from "../schema/index.js"; +import { accountMergeFields, collections, deletedAccountFields } from "../schema/index.js"; /** アカウント削除が依存する外部リソース。テストはエミュレータの Firestore / Auth を差し込む */ export interface AccountDeletionDeps { @@ -118,6 +118,58 @@ export async function sweepDeletedAccounts( return result; } +/** + * 統合の記録を置いてからこの時間が経つまでは定期実行が触れない。 + * 統合のリクエストは記録を置いた直後に同じ削除を行うため、その完了を待たずに並べて削除しないよう、 + * Cloud Functions の HTTP 関数の既定のタイムアウト (60 秒) より十分長くする + */ +export const ACCOUNT_MERGE_MINIMUM_AGE_MS = 10 * 60 * 1000; + +/** completePendingAccountMerges の処理結果 */ +export interface CompletePendingAccountMergesResult { + /** 匿名アカウントを削除して記録を消した件数 */ + completed: number; + /** 失敗して次回に持ち越した件数 */ + failed: number; +} + +/** + * 匿名アカウントの統合で端末を移した後、匿名アカウントの削除が済んでいない記録 (`accountMerges/{uid}`) の削除を完了させる。 + * アプリが送り直しに使う匿名の ID トークンは期限切れになり得るため、サーバー側で完了させる。 + * 削除は deleteUserAccount と同じで冪等。1 件の失敗で残りを止めない (失敗した記録は次回の実行でまた対象になる) + */ +export async function completePendingAccountMerges( + deps: AccountDeletionDeps, + now: Date, + limit: number = SWEEP_BATCH_SIZE, +): Promise { + const merges = await deps.firestore + .collection(collections.accountMerges) + .where(accountMergeFields.mergedAt, "<=", new Date(now.getTime() - ACCOUNT_MERGE_MINIMUM_AGE_MS)) + .limit(limit) + .get(); + const result: CompletePendingAccountMergesResult = { completed: 0, failed: 0 }; + for (const merge of merges.docs) { + try { + await deleteUserAccount(deps, merge.id); + // 記録はこの実行が読んだ版に限って消す。読んだ後に統合の再送が記録を置き直していたら、その再送の削除に任せる + await merge.ref.delete({ lastUpdateTime: merge.updateTime }).catch((deleteError: unknown) => { + if (!isFailedPrecondition(deleteError)) { + throw deleteError; + } + }); + result.completed += 1; + } catch (error) { + result.failed += 1; + logger.error("completing an account merge failed", { error: String(error) }); + } + } + if (merges.size > 0) { + logger.info("completed pending account merges", result); + } + return result; +} + /** * Callable `deleteAccount` の本体。呼び出し元自身のアカウントを削除する。 * 削除対象は必ず ID トークンの uid にする (他人の uid を指定できないよう、リクエストのパラメータからは uid を受け取らない) @@ -203,6 +255,22 @@ export async function authUserExists(auth: AccountDeletionDeps["auth"], uid: str } } +/** + * Firebase Auth のユーザーに今リンクされているプロバイダの ID (匿名ユーザーは空配列)。ユーザーが存在しなければ null。 + * ID トークンの sign_in_provider は発行時点の値で、その後に同じ uid へ Apple をリンクしても有効期限までは変わらないため、 + * 匿名アカウントの統合が統合元の現在の状態を確かめるのに使う + */ +export async function authUserProviderIds(auth: Pick, uid: string): Promise { + try { + return (await auth.getUser(uid)).providerData.map((provider) => provider.providerId); + } catch (error) { + if (isUserNotFound(error)) { + return null; + } + throw error; + } +} + function isUserNotFound(error: unknown): boolean { return typeof error === "object" && error !== null && (error as { code?: string }).code === "auth/user-not-found"; } diff --git a/firebase/functions/src/api/appApi.ts b/firebase/functions/src/api/appApi.ts index fc94192..05912c7 100644 --- a/firebase/functions/src/api/appApi.ts +++ b/firebase/functions/src/api/appApi.ts @@ -10,7 +10,7 @@ import { } from "firebase-admin/firestore"; import { generateApiToken, parseBearerToken } from "../lib/apiToken.js"; import { requireAppCheck } from "../lib/appCheck.js"; -import type { Deps } from "../lib/deps.js"; +import type { Deps, VerifiedIdToken } from "../lib/deps.js"; import { ApiError, badRequestFromZod, errorHandler, notFoundHandler } from "../lib/errors.js"; import { effectivePlan, planLimits } from "../lib/plan.js"; import { decodeCursor, encodeCursor, type ListCursor } from "../lib/cursor.js"; @@ -22,17 +22,22 @@ import { userRef, } from "../lib/store.js"; import { + accountMergeFields, alarmHistoryLimitSchema, canonicalUuidSchema, collections, listCursorSchema, createApiTokenRequestSchema, + mergeAnonymousAccountRequestSchema, registerDeviceRequestSchema, reportDeviceResultRequestSchema, userSchema, type DeviceReport, } from "../schema/index.js"; +/** 匿名認証で発行された ID トークンの `firebase.sign_in_provider` (Firebase Auth の仕様値) */ +const ANONYMOUS_SIGN_IN_PROVIDER = "anonymous"; + /** * 削除処理中 (目印がある) アカウントのデータを作り直さない。 * 削除前に発行された ID トークンで届いた書き込みが、掃除の後にドキュメントを復活させないよう、書き込みと同じトランザクションで判定する @@ -131,14 +136,15 @@ function authenticate(deps: Deps) { next(new ApiError(401, "unauthenticated", "Authorization: Bearer が必要です")); return; } - let uid: string; + let verified: VerifiedIdToken; try { - uid = (await deps.verifyIdToken(idToken)).uid; + verified = await deps.verifyIdToken(idToken); } catch { next(new ApiError(401, "unauthenticated", "ID トークンを検証できませんでした")); return; } - res.locals.uid = uid; + res.locals.uid = verified.uid; + res.locals.signInProvider = verified.signInProvider; next(); }; } @@ -222,6 +228,95 @@ export function createAppApi(deps: Deps): Express { res.status(204).send(); }); + // 匿名ユーザーが既存の Apple アカウントでサインインした時に、匿名側の端末を呼び出し元 (Apple 側) へ移して匿名アカウントを消す。 + // Apple 側を正とし、API トークン・当月の利用数・プランは Apple 側のものを残す (無料枠をリセットしない)。 + // 統合元は ID トークンで本人であることを確かめ、匿名アカウントに限る (識別済みのアカウント同士を統合させない)。 + // 統合元が既に削除済みでも、移す端末が無いだけで同じ結果になる (冪等。応答が失われた後の再送を成功で終わらせる) + app.post("/v1/account/merge", async (req, res) => { + const parsed = mergeAnonymousAccountRequestSchema.safeParse(req.body); + if (!parsed.success) { + throw badRequestFromZod(parsed.error); + } + const uid = currentUid(res); + if (res.locals.signInProvider === ANONYMOUS_SIGN_IN_PROVIDER) { + throw new ApiError(403, "merge_target_anonymous", "統合先は匿名ではないアカウントに限ります"); + } + let anonymous: VerifiedIdToken; + try { + anonymous = await deps.verifyIdToken(parsed.data.anonymous_id_token); + } catch { + throw new ApiError(400, "invalid_anonymous_id_token", "統合元の ID トークンを検証できませんでした"); + } + if (anonymous.signInProvider !== ANONYMOUS_SIGN_IN_PROVIDER) { + throw new ApiError(400, "invalid_anonymous_id_token", "統合元は匿名アカウントに限ります"); + } + if (anonymous.uid === uid) { + throw new ApiError(400, "invalid_anonymous_id_token", "統合元と統合先が同じアカウントです"); + } + // ID トークンの sign_in_provider は発行時点の値のため、発行後に Apple をリンクした uid のトークンも anonymous のまま通る。 + // 今もプロバイダがリンクされていないことを確かめ、識別済みのアカウントを統合元にさせない。 + // ユーザーが既に無い (前回の統合で削除済み) 時は、移す端末が無いだけなので続ける + const anonymousProviderIds = await deps.authUserProviderIds(anonymous.uid); + if (anonymousProviderIds !== null && anonymousProviderIds.length > 0) { + throw new ApiError(400, "invalid_anonymous_id_token", "統合元は匿名アカウントに限ります"); + } + const now = deps.now(); + const userDocRef = userRef(deps.firestore, uid); + const devicesRef = userDocRef.collection(collections.devices); + const anonymousDevicesRef = userRef(deps.firestore, anonymous.uid).collection(collections.devices); + const accountMergeRef = deps.firestore.collection(collections.accountMerges).doc(anonymous.uid); + + const movedDevices = await deps.firestore.runTransaction(async (transaction) => { + await rejectIfAccountDeleted(transaction, deps, uid); + const userSnapshot = await transaction.get(userDocRef); + const anonymousDevices = await transaction.get( + anonymousDevicesRef.orderBy("createdAt", "asc").limit(MAX_DEVICES_PER_USER), + ); + const registered = await transaction.get(devicesRef.limit(MAX_DEVICES_PER_USER)); + const registeredById = new Map(registered.docs.map((doc) => [doc.id, doc])); + // 端末登録と同じ上限を超えて移さない。超えた分は移さずに匿名アカウントと一緒に消える + // (匿名アカウントは端末ごとに作られるため、実際に移るのはサインインした端末の 1 台だけになる) + let remaining = MAX_DEVICES_PER_USER - registered.size; + let moved = 0; + for (const device of anonymousDevices.docs) { + const existing = registeredById.get(device.id); + if (!existing) { + if (remaining <= 0) { + continue; + } + remaining -= 1; + } else if ( + (existing.get("updatedAt") as Timestamp).toMillis() >= (device.get("updatedAt") as Timestamp).toMillis() + ) { + // 同じ端末が Apple 側で登録し直した値の方が新しい (FCM トークンのローテーション後) なら上書きしない + continue; + } + transaction.set(devicesRef.doc(device.id), { + fcmToken: device.get("fcmToken"), + platform: device.get("platform"), + // 無料プランの配送先は最初に登録した 1 台 (createdAt の昇順) のため、移した端末は今 Apple 側に登録した端末として扱い、 + // Apple 側で先に使っていた端末から配送先を奪わない + createdAt: existing ? existing.get("createdAt") : Timestamp.fromDate(now), + updatedAt: Timestamp.fromDate(now), + }); + moved += 1; + } + if (!userSnapshot.exists && moved > 0) { + transaction.set(userDocRef, newUserDocument(now)); + } + // 端末の移動と同じトランザクションで記録を置く。この後の匿名アカウントの削除が失敗し、アプリの送り直しに使う + // 匿名の ID トークンが期限切れになっても、定期実行 (completePendingAccountMerges) が削除を完了させる + transaction.set(accountMergeRef, { + [accountMergeFields.targetUid]: uid, + [accountMergeFields.mergedAt]: Timestamp.fromDate(now), + }); + return moved; + }); + await deps.deleteUserAccount(anonymous.uid); + await accountMergeRef.delete(); + res.status(200).json({ moved_devices: movedDevices }); + }); + // API トークンを発行する。平文はここでしか返さない app.post("/v1/api-tokens", async (req, res) => { const parsed = createApiTokenRequestSchema.safeParse(req.body ?? {}); diff --git a/firebase/functions/src/index.ts b/firebase/functions/src/index.ts index 7544356..457b7ac 100644 --- a/firebase/functions/src/index.ts +++ b/firebase/functions/src/index.ts @@ -9,7 +9,14 @@ import { logger } from "firebase-functions"; import { defineSecret } from "firebase-functions/params"; import { onMessagePublished } from "firebase-functions/pubsub"; import { onSchedule } from "firebase-functions/scheduler"; -import { authUserExists, handleDeleteAccount, sweepDeletedAccounts } from "./account/deleteAccount.js"; +import { + authUserExists, + authUserProviderIds, + completePendingAccountMerges, + deleteUserAccount, + handleDeleteAccount, + sweepDeletedAccounts, +} from "./account/deleteAccount.js"; import { createAppApi } from "./api/appApi.js"; import { createExternalApi } from "./api/externalApi.js"; import { createRevenueCatWebhook } from "./api/revenueCatWebhook.js"; @@ -39,7 +46,7 @@ function createDeps(): Deps { sendPush: createFcmPushSender(getMessaging()), verifyIdToken: async (idToken) => { const decoded = await getAuth().verifyIdToken(idToken); - return { uid: decoded.uid }; + return { uid: decoded.uid, signInProvider: decoded.firebase.sign_in_provider }; }, verifyAppCheckToken: async (appCheckToken) => { const verified = await getAppCheck().verifyToken(appCheckToken); @@ -48,6 +55,8 @@ function createDeps(): Deps { // 監視のみ (monitor) から強制 (enforce) へ段階的に切り替える。値は firebase/functions/.env.<プロジェクト ID> appCheckEnforcementMode: () => parseAppCheckEnforcementMode(process.env.ALARMIFY_APP_CHECK_ENFORCEMENT), authUserExists: (uid) => authUserExists(getAuth(), uid), + authUserProviderIds: (uid) => authUserProviderIds(getAuth(), uid), + deleteUserAccount: (uid) => deleteUserAccount({ firestore: getFirestore(), auth: getAuth() }, uid), // 配送経路は #13 の実機検証で確定する。それまでは環境変数で切り替えられるようにする pushDeliveryMode: () => parsePushDeliveryMode(process.env.ALARMIFY_PUSH_DELIVERY), now: () => new Date(), @@ -106,13 +115,18 @@ export const deleteAccount = onCall( ); /** - * アカウント削除の掃除が途中で失敗した分を完了させる定期実行。 - * 呼び出し元は Auth のユーザーが無くなると再試行できないため、サーバー側の信頼できる経路で残りを消す + * アカウント削除の掃除と、統合した匿名アカウントの削除が途中で失敗した分を完了させる定期実行。 + * 呼び出し元は Auth のユーザーが無くなる・匿名の ID トークンが期限切れになると再試行できないため、サーバー側の信頼できる経路で残りを消す */ export const sweepDeletedAccountsHourly = onSchedule("every 60 minutes", async () => { - const result = await sweepDeletedAccounts({ firestore: getFirestore(), auth: getAuth() }, new Date()); - if (result.failed > 0) { - throw new Error(`${result.failed} deleted account(s) could not be swept`); + const deps = { firestore: getFirestore(), auth: getAuth() }; + // 統合した匿名アカウントの削除を先に完了させる。ここで置いた削除の目印は、Auth のユーザーが消えていれば次回以降の sweep が掃除を終える + const merges = await completePendingAccountMerges(deps, new Date()); + const result = await sweepDeletedAccounts(deps, new Date()); + if (merges.failed > 0 || result.failed > 0) { + throw new Error( + `${result.failed} deleted account(s) could not be swept and ${merges.failed} account merge(s) could not be completed`, + ); } }); diff --git a/firebase/functions/src/lib/deps.ts b/firebase/functions/src/lib/deps.ts index 75f4a31..8cbabf4 100644 --- a/firebase/functions/src/lib/deps.ts +++ b/firebase/functions/src/lib/deps.ts @@ -1,10 +1,13 @@ import type { Firestore } from "firebase-admin/firestore"; +import type { DeleteUserAccountResult } from "../account/deleteAccount.js"; import type { AppCheckEnforcementMode } from "./appCheck.js"; import type { PushDeliveryMode, PushSender } from "./push.js"; /** ID トークンの検証結果のうち、この API が使う部分だけ */ export interface VerifiedIdToken { uid: string; + /** トークンを発行したサインイン方法 (ID トークンの `firebase.sign_in_provider`。匿名認証は "anonymous"、Sign in with Apple は "apple.com") */ + signInProvider: string; } /** App Check トークンの検証結果のうち、この API が使う部分だけ */ @@ -25,6 +28,10 @@ export interface Deps { appCheckEnforcementMode: () => AppCheckEnforcementMode; /** Firebase Auth にユーザーが存在するか。RevenueCat の webhook が users/{uid} を作る前の確認に使う */ authUserExists: (uid: string) => Promise; + /** Firebase Auth のユーザーに今リンクされているプロバイダの ID (`authUserProviderIds`)。匿名なら空配列、存在しなければ null */ + authUserProviderIds: (uid: string) => Promise; + /** アカウントのサーバー上のデータと Firebase Auth のユーザーを削除する (`deleteUserAccount`)。匿名アカウントの統合が使う */ + deleteUserAccount: (uid: string) => Promise; pushDeliveryMode: () => PushDeliveryMode; now: () => Date; } diff --git a/firebase/functions/src/schema/accountMerge.ts b/firebase/functions/src/schema/accountMerge.ts new file mode 100644 index 0000000..ac929d5 --- /dev/null +++ b/firebase/functions/src/schema/accountMerge.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { timestampSchema } from "./firestore.js"; + +/** + * accountMerges/{匿名アカウントの uid}。 + * 匿名アカウントの端末を統合先 (Sign in with Apple のアカウント) へ移した時に置き、匿名アカウントの削除が済んだら消す。 + * 削除が途中で失敗し、アプリが送り直しに使う匿名の ID トークンが期限切れになっても、定期実行がこの記録を頼りに削除を完了させる + */ +export const accountMergeSchema = z.object({ + /** 端末を移した先のアカウントの uid */ + targetUid: z.string(), + /** 端末を移した時刻。定期実行はこの値が十分に古い記録だけを対象にする */ + mergedAt: timestampSchema, +}); +export type AccountMerge = z.infer; + +/** accountMerges/{uid} のフィールド名。書き込み・クエリ・テストはこの定数を通す */ +export const accountMergeFields = { + targetUid: "targetUid", + mergedAt: "mergedAt", +} as const satisfies Record; diff --git a/firebase/functions/src/schema/firestore.ts b/firebase/functions/src/schema/firestore.ts index a5b3c2b..4a84b76 100644 --- a/firebase/functions/src/schema/firestore.ts +++ b/firebase/functions/src/schema/firestore.ts @@ -9,6 +9,8 @@ export const collections = { alarms: "alarms", /** 削除処理中のアカウントの目印 (`deletedAccounts/{uid}`) */ deletedAccounts: "deletedAccounts", + /** 統合した匿名アカウントの削除が済むまでの記録 (`accountMerges/{匿名アカウントの uid}`) */ + accountMerges: "accountMerges", /** 予算通知を Slack へ転送した記録 (`budgetNotifications/{budgetId}`) */ budgetNotifications: "budgetNotifications", } as const; diff --git a/firebase/functions/src/schema/index.ts b/firebase/functions/src/schema/index.ts index 832a897..cbc4bc3 100644 --- a/firebase/functions/src/schema/index.ts +++ b/firebase/functions/src/schema/index.ts @@ -1,3 +1,4 @@ +export * from "./accountMerge.js"; export * from "./alarm.js"; export * from "./apiToken.js"; export * from "./budgetNotification.js"; diff --git a/firebase/functions/src/schema/request.ts b/firebase/functions/src/schema/request.ts index b5ef832..ad638a7 100644 --- a/firebase/functions/src/schema/request.ts +++ b/firebase/functions/src/schema/request.ts @@ -122,6 +122,16 @@ export const createApiTokenRequestSchema = z.object({ }); export type CreateApiTokenRequest = z.infer; +/** + * アプリ向け: POST /v1/account/merge + * anonymous_id_token は統合元 (匿名アカウント) の Firebase ID トークン。Firebase の ID トークンは 1 KB 前後の JWT で、 + * 上限は express.json の 32kb より十分小さく、署名の検証に渡す前に異常な長さを弾ける 4096 文字にする + */ +export const mergeAnonymousAccountRequestSchema = z.object({ + anonymous_id_token: z.string().min(1).max(4096), +}); +export type MergeAnonymousAccountRequest = z.infer; + /** アプリ向け: GET /v1/alarms の limit。上限なしの取得を書かない (.claude/rules/firestore-db-rules.md) */ export const alarmHistoryLimitSchema = z.coerce.number().int().min(1).max(100).default(50); diff --git a/firebase/functions/test/alarmFlow.test.ts b/firebase/functions/test/alarmFlow.test.ts index 02f30f8..35ae661 100644 --- a/firebase/functions/test/alarmFlow.test.ts +++ b/firebase/functions/test/alarmFlow.test.ts @@ -10,8 +10,10 @@ import { toIso8601Seconds } from "../src/lib/push.js"; import { MAX_DEVICES_PER_USER } from "../src/lib/store.js"; import { MAX_FIRE_AT_AHEAD_DAYS, MIN_FIRE_AT_LEAD_SECONDS } from "../src/api/externalApi.js"; import { userRef } from "../src/lib/store.js"; -import { collections } from "../src/schema/index.js"; +import { accountMergeFields, collections, deletedAccountFields } from "../src/schema/index.js"; import { + ANONYMOUS_ID_TOKEN, + ANONYMOUS_UID, clearFirestore, createTestContext, startTestServer, @@ -1588,3 +1590,212 @@ describe("端末からの反映結果の報告", () => { ]); }); }); + +describe("匿名アカウントの統合", () => { + /** + * 匿名アカウント側に端末・API トークン・アラーム・利用数を書き込む (アプリの匿名認証で使っていた状態)。 + * 端末の既定の updatedAt は TEST_NOW で、Apple 側で同じ端末を登録し直した値との新旧を比べるテストだけが古い日時を渡す + */ + async function seedAnonymousAccount(deviceId = "device-anonymous", updatedAt = TEST_NOW): Promise { + const anonymousUser = userRef(context.deps.firestore, ANONYMOUS_UID); + const batch = context.deps.firestore.batch(); + batch.set(anonymousUser, { + plan: "free", + monthlyUsage: { month: "2026-09", scheduledAlarmCount: 7 }, + createdAt: Timestamp.fromDate(TEST_NOW), + updatedAt: Timestamp.fromDate(TEST_NOW), + }); + batch.set(anonymousUser.collection(collections.devices).doc(deviceId), { + fcmToken: "fcm-token-anonymous", + platform: "ios", + createdAt: Timestamp.fromDate(new Date("2026-08-01T00:00:00Z")), + updatedAt: Timestamp.fromDate(updatedAt), + }); + batch.set(anonymousUser.collection(collections.apiTokens).doc("token-anonymous"), { hash: "dummy-hash", revokedAt: null }); + batch.set(anonymousUser.collection(collections.alarms).doc("alarm-anonymous"), { title: "Deploy finished" }); + await batch.commit(); + } + + /** + * 統合先 (VALID_ID_TOKEN) として統合を呼ぶ。統合元の既定は正しい匿名アカウントのトークンで、 + * 拒否の確認をするテストだけが別のトークンを渡す + */ + function merge(anonymousIdToken: string = ANONYMOUS_ID_TOKEN) { + return request(appApi) + .post("/v1/account/merge") + .set("authorization", `Bearer ${VALID_ID_TOKEN}`) + .set(APP_CHECK_HEADER, VALID_APP_CHECK_TOKEN) + .send({ anonymous_id_token: anonymousIdToken }); + } + + /** ユーザーに登録されている端末の device_id (昇順) */ + async function deviceIds(uid: string): Promise { + const snapshot = await userRef(context.deps.firestore, uid).collection(collections.devices).get(); + return snapshot.docs.map((doc) => doc.id).sort(); + } + + beforeEach(() => { + context.setSignInProvider("apple.com"); + }); + + it("匿名側の端末を Apple 側へ移し、API トークンと利用数は Apple 側のまま残して匿名アカウントを消す", async () => { + await registerDevice("device-apple", "fcm-token-apple"); + const issued = await issueApiToken("apple-token"); + await seedAnonymousAccount(); + const mergedAt = new Date("2026-09-02T01:00:00Z"); + context.setNow(mergedAt); + + const response = await merge().expect(200); + + expect(response.body.moved_devices).toBe(1); + expect(await deviceIds(context.uid)).toEqual(["device-anonymous", "device-apple"]); + const moved = await userRef(context.deps.firestore, context.uid) + .collection(collections.devices) + .doc("device-anonymous") + .get(); + expect(moved.get("fcmToken")).toBe("fcm-token-anonymous"); + // 無料プランの配送先 (最初に登録した 1 台) を Apple 側の端末から奪わないよう、移した時刻で登録したことにする + expect((moved.get("createdAt") as Timestamp).toMillis()).toBe(mergedAt.getTime()); + const tokens = await userRef(context.deps.firestore, context.uid).collection(collections.apiTokens).get(); + expect(tokens.docs.map((doc) => doc.id)).toEqual([issued.id]); + const appleUser = await userRef(context.deps.firestore, context.uid).get(); + expect(appleUser.get("monthlyUsage.scheduledAlarmCount")).toBe(0); + + expect(context.deletedAuthUids).toEqual([ANONYMOUS_UID]); + expect((await userRef(context.deps.firestore, ANONYMOUS_UID).get()).exists).toBe(false); + expect(await deviceIds(ANONYMOUS_UID)).toEqual([]); + const anonymousTokens = await userRef(context.deps.firestore, ANONYMOUS_UID).collection(collections.apiTokens).get(); + expect(anonymousTokens.size).toBe(0); + }); + + it("匿名アカウントを消せたら統合の記録を残さない", async () => { + await seedAnonymousAccount(); + + await merge().expect(200); + + expect((await context.deps.firestore.collection(collections.accountMerges).doc(ANONYMOUS_UID).get()).exists).toBe(false); + }); + + it("匿名アカウントの削除に失敗したら、定期実行が削除を完了できるよう統合の記録を残す", async () => { + await seedAnonymousAccount(); + const failingApi = await startTestServer( + createAppApi({ + ...context.deps, + deleteUserAccount: async () => { + throw new Error("auth unavailable"); + }, + }), + ); + try { + await request(failingApi) + .post("/v1/account/merge") + .set("authorization", `Bearer ${VALID_ID_TOKEN}`) + .set(APP_CHECK_HEADER, VALID_APP_CHECK_TOKEN) + .send({ anonymous_id_token: ANONYMOUS_ID_TOKEN }) + .expect(500); + } finally { + await stopTestServer(failingApi); + } + + const record = await context.deps.firestore.collection(collections.accountMerges).doc(ANONYMOUS_UID).get(); + expect(record.get(accountMergeFields.targetUid)).toBe(context.uid); + expect(await deviceIds(context.uid)).toEqual(["device-anonymous"]); + }); + + it("Apple 側にドキュメントが無くても、端末を移してユーザードキュメントを作る", async () => { + await seedAnonymousAccount(); + + await merge().expect(200); + + expect(await deviceIds(context.uid)).toEqual(["device-anonymous"]); + expect((await userRef(context.deps.firestore, context.uid).get()).get("plan")).toBe("free"); + }); + + it("再送しても成功し、移した端末は変わらない (冪等)", async () => { + await seedAnonymousAccount(); + await merge().expect(200); + + const response = await merge().expect(200); + + expect(response.body.moved_devices).toBe(0); + expect(await deviceIds(context.uid)).toEqual(["device-anonymous"]); + }); + + it("同じ端末を Apple 側で登録し直した値の方が新しければ上書きしない", async () => { + await seedAnonymousAccount("device-1", new Date("2026-09-01T00:00:00Z")); + await registerDevice("device-1", "fcm-token-rotated"); + + const response = await merge().expect(200); + + expect(response.body.moved_devices).toBe(0); + const device = await userRef(context.deps.firestore, context.uid).collection(collections.devices).doc("device-1").get(); + expect(device.get("fcmToken")).toBe("fcm-token-rotated"); + }); + + it("Apple 側の端末が上限に達していれば移さずに匿名アカウントを消す", async () => { + for (let index = 0; index < MAX_DEVICES_PER_USER; index += 1) { + await registerDevice(`device-${index}`, `fcm-token-${index}`); + } + await seedAnonymousAccount(); + + const response = await merge().expect(200); + + expect(response.body.moved_devices).toBe(0); + expect(await deviceIds(context.uid)).toHaveLength(MAX_DEVICES_PER_USER); + expect(context.deletedAuthUids).toEqual([ANONYMOUS_UID]); + }); + + it("統合先が匿名アカウントなら 403 で何も変えない", async () => { + context.setSignInProvider("anonymous"); + await seedAnonymousAccount(); + + const response = await merge().expect(403); + + expect(response.body.error.code).toBe("merge_target_anonymous"); + expect(await deviceIds(ANONYMOUS_UID)).toEqual(["device-anonymous"]); + expect(context.deletedAuthUids).toEqual([]); + }); + + it("統合元の ID トークンが検証できない・匿名でない・統合先と同じなら 400", async () => { + await seedAnonymousAccount(); + + const invalid = await merge("invalid").expect(400); + expect(invalid.body.error.code).toBe("invalid_anonymous_id_token"); + // VALID_ID_TOKEN は統合先 (apple.com) 自身のトークン + const identified = await merge(VALID_ID_TOKEN).expect(400); + expect(identified.body.error.code).toBe("invalid_anonymous_id_token"); + await request(appApi) + .post("/v1/account/merge") + .set("authorization", `Bearer ${VALID_ID_TOKEN}`) + .set(APP_CHECK_HEADER, VALID_APP_CHECK_TOKEN) + .send({}) + .expect(400); + + expect(await deviceIds(ANONYMOUS_UID)).toEqual(["device-anonymous"]); + expect(context.deletedAuthUids).toEqual([]); + }); + + it("統合元の ID トークンが匿名のままでも、発行後に Apple をリンクしたアカウントなら 400 で何も変えない", async () => { + await seedAnonymousAccount(); + context.setAnonymousUserProviderIds(["apple.com"]); + + const response = await merge().expect(400); + + expect(response.body.error.code).toBe("invalid_anonymous_id_token"); + expect(await deviceIds(ANONYMOUS_UID)).toEqual(["device-anonymous"]); + expect(context.deletedAuthUids).toEqual([]); + }); + + it("統合先のアカウントが削除処理中なら 410 で匿名アカウントに触れない", async () => { + await seedAnonymousAccount(); + await context.deps.firestore.collection(collections.deletedAccounts).doc(context.uid).set({ + [deletedAccountFields.requestedAt]: Timestamp.fromDate(TEST_NOW), + }); + + const response = await merge().expect(410); + + expect(response.body.error.code).toBe("account_deleted"); + expect(await deviceIds(ANONYMOUS_UID)).toEqual(["device-anonymous"]); + expect(context.deletedAuthUids).toEqual([]); + }); +}); diff --git a/firebase/functions/test/deleteAccount.test.ts b/firebase/functions/test/deleteAccount.test.ts index cce596b..02074ba 100644 --- a/firebase/functions/test/deleteAccount.test.ts +++ b/firebase/functions/test/deleteAccount.test.ts @@ -2,13 +2,16 @@ import { getAuth, type Auth } from "firebase-admin/auth"; import { Timestamp } from "firebase-admin/firestore"; import { beforeEach, describe, expect, it } from "vitest"; import { + ACCOUNT_MERGE_MINIMUM_AGE_MS, + authUserProviderIds, + completePendingAccountMerges, DELETION_MARKER_MINIMUM_AGE_MS, deleteUserAccount, handleDeleteAccount, sweepDeletedAccounts, type AccountDeletionDeps, } from "../src/account/deleteAccount.js"; -import { collections, deletedAccountFields } from "../src/schema/index.js"; +import { accountMergeFields, collections, deletedAccountFields } from "../src/schema/index.js"; import request from "supertest"; import { createAppApi } from "../src/api/appApi.js"; import { APP_CHECK_HEADER } from "../src/lib/appCheck.js"; @@ -120,6 +123,17 @@ describe("アカウント削除", () => { expect((await marker(uid).get()).exists).toBe(true); }); + it("リンク中のプロバイダは匿名なら空、Apple をリンクした後はその ID、ユーザーが無ければ null を返す", async () => { + const uid = await signUpAnonymously(); + expect(await authUserProviderIds(auth, uid)).toEqual([]); + + await auth.updateUser(uid, { providerToLink: { providerId: "apple.com", uid: "apple-user-identifier" } }); + expect(await authUserProviderIds(auth, uid)).toEqual(["apple.com"]); + + await auth.deleteUser(uid); + expect(await authUserProviderIds(auth, uid)).toBeNull(); + }); + it("削除済みの uid で再実行しても成功する (冪等)", async () => { const uid = await signUpAnonymously(); await seedUserData(uid); @@ -246,6 +260,51 @@ describe("アカウント削除", () => { }); }); +describe("統合した匿名アカウントの削除の完了", () => { + function mergeRecord(uid: string) { + return deps.firestore.collection(collections.accountMerges).doc(uid); + } + + it("十分に古い統合の記録の匿名アカウントを削除して記録を消し、新しい記録には触れない", async () => { + const staleUid = await signUpAnonymously(); + const freshUid = await signUpAnonymously(); + await seedUserData(staleUid); + await seedUserData(freshUid); + await mergeRecord(staleUid).set({ + [accountMergeFields.targetUid]: "apple-uid", + [accountMergeFields.mergedAt]: Timestamp.fromMillis(TEST_NOW.getTime() - ACCOUNT_MERGE_MINIMUM_AGE_MS - 60_000), + }); + await mergeRecord(freshUid).set({ + [accountMergeFields.targetUid]: "apple-uid", + [accountMergeFields.mergedAt]: Timestamp.fromDate(TEST_NOW), + }); + + const result = await completePendingAccountMerges(deps, TEST_NOW); + + expect(result).toEqual({ completed: 1, failed: 0 }); + expect(await authUserExists(staleUid)).toBe(false); + expect(await remainingDocumentCount(staleUid)).toBe(0); + expect((await mergeRecord(staleUid).get()).exists).toBe(false); + expect(await authUserExists(freshUid)).toBe(true); + expect(await remainingDocumentCount(freshUid)).toBe(4); + expect((await mergeRecord(freshUid).get()).exists).toBe(true); + }); + + it("匿名アカウントが既に削除済みでも記録を消して終わる (冪等)", async () => { + const uid = await signUpAnonymously(); + await deleteUserAccount(deps, uid); + await mergeRecord(uid).set({ + [accountMergeFields.targetUid]: "apple-uid", + [accountMergeFields.mergedAt]: Timestamp.fromMillis(TEST_NOW.getTime() - ACCOUNT_MERGE_MINIMUM_AGE_MS - 60_000), + }); + + const result = await completePendingAccountMerges(deps, TEST_NOW); + + expect(result).toEqual({ completed: 1, failed: 0 }); + expect((await mergeRecord(uid).get()).exists).toBe(false); + }); +}); + describe("削除の掃除 (sweep)", () => { it("Auth の削除後に残ったデータを消して目印を外し、目印の無いアカウントには触れない", async () => { const uid = await signUpAnonymously(); diff --git a/firebase/functions/test/helpers.ts b/firebase/functions/test/helpers.ts index f1f5215..666a39c 100644 --- a/firebase/functions/test/helpers.ts +++ b/firebase/functions/test/helpers.ts @@ -4,6 +4,7 @@ import type { Express } from "express"; import { getApps, initializeApp } from "firebase-admin/app"; import { getFirestore, type Firestore } from "firebase-admin/firestore"; import type { Message, TokenMessage } from "firebase-admin/messaging"; +import { deleteUserAccount } from "../src/account/deleteAccount.js"; import type { AppCheckEnforcementMode } from "../src/lib/appCheck.js"; import type { Deps } from "../src/lib/deps.js"; @@ -12,6 +13,10 @@ export const PROJECT_ID = "demo-alarmify"; export const TEST_NOW = new Date("2026-09-02T00:00:00Z"); export const VALID_ID_TOKEN = "valid-id-token"; export const VALID_APP_CHECK_TOKEN = "valid-app-check-token"; +/** 匿名アカウントの統合で、統合元として渡す匿名ユーザーの ID トークン */ +export const ANONYMOUS_ID_TOKEN = "anonymous-id-token"; +/** ANONYMOUS_ID_TOKEN を検証した時の uid */ +export const ANONYMOUS_UID = "anonymous-uid"; export function emulatorHost(): string { const host = process.env.FIRESTORE_EMULATOR_HOST; @@ -50,6 +55,12 @@ export interface TestContext { throwNextPush(): void; /** Firebase Auth にユーザーが存在するかの応答を差し替える (既定は存在する) */ setAuthUserExists(exists: boolean): void; + /** VALID_ID_TOKEN のサインイン方法を差し替える (既定はアプリの初回起動と同じ匿名認証) */ + setSignInProvider(signInProvider: string): void; + /** deleteUserAccount が Firebase Auth から削除した uid (呼び出し順) */ + deletedAuthUids: string[]; + /** ANONYMOUS_UID のユーザーに今リンクされているプロバイダを差し替える (既定は匿名のまま = 空配列) */ + setAnonymousUserProviderIds(providerIds: string[]): void; } /** @@ -63,9 +74,14 @@ export function createTestContext(uid = "test-uid"): TestContext { let failNextToken: string | null = null; let throwNext = false; let authUserExists = true; + // アプリは初回起動で匿名認証するため、既存のテストが前提にしてきた状態 (匿名アカウント) を既定にする + let signInProvider = "anonymous"; const sentBatches: Message[][] = []; + const deletedAuthUids: string[] = []; + let anonymousUserProviderIds: string[] = []; + const firestore = testFirestore(); const deps: Deps = { - firestore: testFirestore(), + firestore, sendPush: async (messages) => { sentBatches.push(messages); if (throwNext) { @@ -89,10 +105,13 @@ export function createTestContext(uid = "test-uid"): TestContext { return { successCount: messages.length, failureCount: 0, errors: [] }; }, verifyIdToken: async (idToken) => { + if (idToken === ANONYMOUS_ID_TOKEN) { + return { uid: ANONYMOUS_UID, signInProvider: "anonymous" }; + } if (idToken !== VALID_ID_TOKEN) { throw new Error("invalid id token"); } - return { uid }; + return { uid, signInProvider }; }, verifyAppCheckToken: async (appCheckToken) => { if (appCheckToken !== VALID_APP_CHECK_TOKEN) { @@ -102,6 +121,24 @@ export function createTestContext(uid = "test-uid"): TestContext { }, appCheckEnforcementMode: () => appCheckEnforcementMode, authUserExists: async () => authUserExists, + // ANONYMOUS_UID 以外は、統合元として渡すテストが無いため Apple をリンク済みとして返す + authUserProviderIds: async (targetUid) => (targetUid === ANONYMOUS_UID ? anonymousUserProviderIds : ["apple.com"]), + // Firestore 側の削除は本物を通し、Auth のユーザーの削除だけを記録に置き換える (Auth エミュレータのユーザーを用意しなくて済むようにする) + deleteUserAccount: (targetUid) => + deleteUserAccount( + { + firestore, + auth: { + deleteUser: async (deletedUid) => { + deletedAuthUids.push(deletedUid); + }, + getUser: async () => { + throw Object.assign(new Error("user not found"), { code: "auth/user-not-found" }); + }, + }, + }, + targetUid, + ), pushDeliveryMode: () => "notification-service", now: () => now, }; @@ -127,6 +164,13 @@ export function createTestContext(uid = "test-uid"): TestContext { setAuthUserExists: (exists) => { authUserExists = exists; }, + setSignInProvider: (provider) => { + signInProvider = provider; + }, + deletedAuthUids, + setAnonymousUserProviderIds: (providerIds) => { + anonymousUserProviderIds = providerIds; + }, }; }