diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 67f971d..23466e6 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; }; E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; }; F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; }; + A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -115,6 +116,7 @@ E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = ""; }; F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = ""; }; + A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = ""; }; F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = ""; }; FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ @@ -199,6 +201,7 @@ C0AC4A07811751FB78AA0002 /* CoachAuth.swift */, D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */, F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */, + A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */, E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, @@ -396,6 +399,7 @@ C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */, D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */, F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */, + A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */, 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */, E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */, 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */, diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements index 5bbeb13..5aacad2 100644 --- a/ios/App/App/App.entitlements +++ b/ios/App/App/App.entitlements @@ -8,5 +8,9 @@ com.apple.developer.healthkit.background-delivery + com.apple.developer.applesignin + + Default + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 3e2758b..3429e38 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -22,6 +22,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let token = CoachAuth.kCoachWebToken guard !token.isEmpty, token != "REPLACE_ME" else { return } + // N'écrase PAS une session existante (ex. login natif Apple = cookie + // de session per-user déjà posé). On n'injecte le token bypass que si + // aucun cookie coach_web_token n'est présent (1er lancement). + if let cookies = HTTPCookieStorage.shared.cookies, + cookies.contains(where: { $0.name == "coach_web_token" && $0.domain.contains("coach.hypnotruck.ch") }) { + return + } + let oneYear: TimeInterval = 60 * 60 * 24 * 365 let properties: [HTTPCookiePropertyKey: Any] = [ .name: "coach_web_token", diff --git a/ios/App/App/CoachAppleAuth.swift b/ios/App/App/CoachAppleAuth.swift new file mode 100644 index 0000000..40db893 --- /dev/null +++ b/ios/App/App/CoachAppleAuth.swift @@ -0,0 +1,101 @@ +// CoachAppleAuth.swift +// Plugin Capacitor natif : Sign in with Apple via AuthenticationServices. +// +// Usage côté JS (WebView) : +// const r = await window.Capacitor.Plugins.CoachAppleAuth.signIn(); +// → { identityToken, user, authorizationCode?, email?, givenName?, familyName? } +// → reject("canceled") si l'utilisateur annule, sinon reject(message) +// +// L'`identityToken` est un JWT signé par Apple dont l'`aud` = bundle id +// (ch.hypnotruck.coach). On le POST sur /auth/apple/web côté backend qui le +// vérifie contre le JWKS Apple (APPLE_CLIENT_IDS inclut le bundle id) puis +// pose le cookie de session. Email/nom ne sont fournis qu'au 1er sign-in. +import Foundation +import Capacitor +import AuthenticationServices + +@objc(CoachAppleAuthPlugin) +public class CoachAppleAuthPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "CoachAppleAuthPlugin" + public let jsName = "CoachAppleAuth" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "signIn", returnType: CAPPluginReturnPromise), + ] + + private var pendingCall: CAPPluginCall? + + @objc func isAvailable(_ call: CAPPluginCall) { + if #available(iOS 13.0, *) { + call.resolve(["available": true]) + } else { + call.resolve(["available": false, "reason": "iOS 13+ required"]) + } + } + + @objc func signIn(_ call: CAPPluginCall) { + guard #available(iOS 13.0, *) else { + call.reject("Sign in with Apple requires iOS 13+") + return + } + self.pendingCall = call + DispatchQueue.main.async { + let provider = ASAuthorizationAppleIDProvider() + let request = provider.createRequest() + request.requestedScopes = [.fullName, .email] + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } +} + +@available(iOS 13.0, *) +extension CoachAppleAuthPlugin: ASAuthorizationControllerDelegate { + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let cred = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = cred.identityToken, + let identityToken = String(data: tokenData, encoding: .utf8) else { + pendingCall?.reject("No identity token returned by Apple") + pendingCall = nil + return + } + var result: [String: Any] = [ + "identityToken": identityToken, + "user": cred.user, + ] + if let codeData = cred.authorizationCode, + let code = String(data: codeData, encoding: .utf8) { + result["authorizationCode"] = code + } + if let email = cred.email { result["email"] = email } + if let given = cred.fullName?.givenName { result["givenName"] = given } + if let family = cred.fullName?.familyName { result["familyName"] = family } + pendingCall?.resolve(result) + pendingCall = nil + } + + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + let nsErr = error as NSError + if nsErr.code == ASAuthorizationError.canceled.rawValue { + pendingCall?.reject("canceled") + } else { + pendingCall?.reject("Apple sign-in failed: \(error.localizedDescription)") + } + pendingCall = nil + } +} + +@available(iOS 13.0, *) +extension CoachAppleAuthPlugin: ASAuthorizationControllerPresentationContextProviding { + public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return self.bridge?.viewController?.view.window ?? ASPresentationAnchor() + } +}