feat(ios): plugin natif Sign in with Apple (CoachAppleAuth)

- CoachAppleAuth.swift : plugin Capacitor (AuthenticationServices) → signIn()
  renvoie { identityToken, user, email?, givenName? } au JS, POSTé sur
  /auth/apple/web (aud = bundle id ch.hypnotruck.coach).
- App.entitlements : capability com.apple.developer.applesignin (Default).
- project.pbxproj : enregistre CoachAppleAuth.swift dans la target App.
- AppDelegate : l'injection du cookie bypass n'écrase plus une session
  existante (sinon le login natif serait remplacé à chaque lancement).

Côté Xcode : ajouter la capability 'Sign in with Apple' puis build device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-06-04 19:05:52 +00:00
parent 585e1233db
commit bf975ce8e6
4 changed files with 117 additions and 0 deletions

View File

@@ -37,6 +37,7 @@
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; }; E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; };
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; }; E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; };
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -115,6 +116,7 @@
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; }; E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = "<group>"; }; EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = "<group>"; };
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = "<group>"; }; F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = "<group>"; };
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; }; F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; };
FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -199,6 +201,7 @@
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */, C0AC4A07811751FB78AA0002 /* CoachAuth.swift */,
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */, D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */,
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */, F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */,
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */,
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */, E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */,
504EC30B1FED79650016851F /* Main.storyboard */, 504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */, 504EC30E1FED79650016851F /* Assets.xcassets */,
@@ -396,6 +399,7 @@
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */, C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */, D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */,
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */, F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */,
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */, 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */, E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */, 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,

View File

@@ -8,5 +8,9 @@
<true/> <true/>
<key>com.apple.developer.healthkit.background-delivery</key> <key>com.apple.developer.healthkit.background-delivery</key>
<true/> <true/>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -22,6 +22,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
let token = CoachAuth.kCoachWebToken let token = CoachAuth.kCoachWebToken
guard !token.isEmpty, token != "REPLACE_ME" else { return } 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 oneYear: TimeInterval = 60 * 60 * 24 * 365
let properties: [HTTPCookiePropertyKey: Any] = [ let properties: [HTTPCookiePropertyKey: Any] = [
.name: "coach_web_token", .name: "coach_web_token",

View File

@@ -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()
}
}