FlutteriOSKeychainSecurityArabic

مشاركة الـ Keychain وربط الـ Login بين عدة تطبيقات في iOS & Flutter 🔐

June 13, 20267 min read

السلام عليكم! وأنا ببني نظام Login بين تطبيقين مختلفين، كان عندي 3 متطلبات أساسية: 1. Auto Login 2. Independent Logout 3. Security والـ Solution كان أذكى بكتير من مجرد مشاركة Access Token عادي. خلينا نفهم الموضوع من أول وجديد لأن نظام التشغيل iOS فيه أكتر من طريقة، وكل طريقة مناسبة لسيناريو مختلف.

المشكلة

عندك تطبيقين: App A و App B، وعندك Token بيتولد بعد الـ Login. وعايز App B يقدر يستخدم نفس الـ Token اللي اتخزن في App A بشكل تلقائي.

في iOS بشكل افتراضي: App A Storage App B Storage
App B Storage App A Storage
كل تطبيق معزول تمامًا داخل الـ Sandbox الخاص به لأسباب أمنية.

الحل الأول: App Groups

Apple بتسمح لمجموعة تطبيقات من نفس المطور بتشارك مساحة تخزين مشتركة تسمى Shared Container.

App A                App B
                   /   
    --> Shared Container <--    

الـ Shared Container ده ممكن تخزن فيه: UserDefaults Files & Cache Settings

الخطوات:

  1. إنشاء App Group: في حساب Apple Developer -> Identifiers -> App Groups وننشئ جروب باسم group.com.codex.shared.
  2. ربطه بالتطبيقين: في Xcode لكل تطبيق من Signing & Capabilities -> نضيف Capability ونختار App Groups ونفعل الجروب المشترك.
  3. الكتابة (Swift):
write_data.swift
let defaults = UserDefaults(suiteName: "group.com.codex.shared")
defaults?.set("TOKEN_123", forKey: "token")

4. القراءة في App B:

read_data.swift
let defaults = UserDefaults(suiteName: "group.com.codex.shared")
let token = defaults?.string(forKey: "token")

العيوب الكبرى للـ App Groups:

أي تطبيق داخل الـ Group يقدر يقرأ القيمة بسهولة لأنها غير مشفرة (Plaintext). لذلك: Access Token، Refresh Token، أو Master Token يفضل ألا يتم تخزينهم هنا نهائيًا.

الحل الثاني: Shared Keychain (الموصى به)

وهو الحل اللي Apple نفسها بتوصي بيه لحفظ الـ Tokens والبيانات الحساسة.

ما هو الـ Keychain؟

بدل استخدام الـ UserDefaults، بنستخدم الـ iOS Keychain وهو خزانة مشفرة ومؤمنة على مستوى نظام التشغيل لتخزين البيانات الحساسة مثل: Passwords Tokens Certificates

بشكل افتراضي، الـ Keychain الخاص بكل تطبيق يكون معزولاً تماماً، ولكن بتفعيل ميزة Keychain Sharing يمكننا مشاركة بيانات الـ Keychain بين تطبيقات من نفس المطور.

الخطوات:

  1. تفعيل Keychain Sharing: في Xcode لكل تطبيق، من Signing & Capabilities -> نضيف Capability -> Keychain Sharing ونضيف الجروب المشترك باستخدام الـ Team ID كبادئة: ABDCDE124.com.groupName.shared.
  2. التخزين من App A (Swift): نستخدم الـ kSecAttrAccessGroup لتحديد الـ Shared Group:
keychain_write.swift
let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "master_token",
    kSecAttrAccessGroup as String: "ABDCDE124.com.groupName.shared",
    kSecValueData as String: token.data(using: .utf8)!
]

3. القراءة من App B: يستخدم نفس الـ Group ID ويقرأ الـ Token بنفس الطريقة.

هيكل الـ Architecture الذكي: Master Token + App Tokens

لو قمنا بتخزين نفس الـ Access Token مباشرة في الـ Shared Keychain وتشاركناه، فإنه عند تسجيل الخروج (Logout) من App A، سيتم حذف الـ Token مما يؤدي لتسجيل خروج المستخدم من App B تلقائيًا، وهذا ليس مرغوبًا دائمًا (Independent Logout).

التصميم الممتاز هو استخدام Master Token و App Tokens:

البيانات المشتركة (Shared)

Shared Keychain: يحتفظ بالـ Master Token فقط.

بيانات كل تطبيق (Local)

App A: يحتفظ بـ Access Token A الخاص به محليًا.
App B: يحتفظ بـ Access Token B الخاص به محليًا.

الـ Flows الأساسية:

1. الدخول لأول مرة (First Login):

تسجيل الدخول في App A -> استلام Master Token -> حفظه في الـ Shared Keychain.

2. تسجيل الدخول التلقائي (Auto Login في App B):

فتح App B -> فحص الـ Shared Keychain -> الـ Master Token موجود -> إرسال طلب للـ Backend للحصول على App Token B -> تسجيل الدخول تلقائيًا بدون طلب كلمة مرور.

3. تسجيل خروج مستقل (Independent Logout):

تسجيل خروج من App A -> مسح Access Token A المحلي فقط -> يظل App B يعمل بـ Access Token B دون تأثر.

4. تسجيل خروج من جميع التطبيقات (Logout All):

مسح الـ Master Token من الـ Shared Keychain. عند فتح أي تطبيق لاحقًا لن يجد الـ Master Token وبالتالي سيوجه المستخدم لصفحة تسجيل الدخول.

الاستخدام في Flutter

في Flutter نستخدم باكدج flutter_secure_storage ونهيئها باستخدام الـ groupId المشترك:

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const storage = FlutterSecureStorage(
  iOptions: IOSOptions(
    groupId: 'ABDCDE124.com.groupName.shared',
  ),
);

// كتابة الـ Master Token
await storage.write(
  key: 'master_token',
  value: token,
);

// قراءة الـ Master Token من أي تطبيق آخر في نفس الـ Group
String? masterToken = await storage.read(key: 'master_token');

Keychain Sharing Configurations

هنا تفاصيل الإعدادات الفعلية التي قمنا بتطبيقها لمشاركة الـ Keychain بين تطبيقي EasyParking و Control App باستخدام ميزة حل الـ Team ID بشكل ديناميكي لتفادي أي مشاكل في بيئة الـ Simulators والـ Development Profiles المختلفة.

1 Xcode Entitlements (`Runner.entitlements`)

يجب أن يحدد كلا التطبيقين نفس الـ Keychain Sharing Group تماماً في ملف الـ entitlements. لضمان عملها بشكل صحيح قمنا بكتابة الـ Group المشترك كالتالي:

مسار الملف في كلا التطبيقين: ios/Runner/Runner.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>keychain-access-groups</key>
	<array>
		<string>ABDCDE124.com.groupName.shared</string>
	</array>
</dict>
</plist>

2 iOS Native Side (`AppDelegate.swift`)

لتفادي مشكلة اختلاف الـ Team ID أو عدم قدرة الـ Simulator على قراءتها محلياً، قمنا ببناء Swift Platform Channel مخصص يقوم بالبحث عن الـ Team ID الفعلي للـ Build الحالي ديناميكياً من الـ Keychain ثم بناء الـ Group ID.

الملفات المتأثرة: EasyParking: ios/Runner/AppDelegate.swift Control App: ios/Runner/AppDelegate.swift

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
    GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)

    let channel = FlutterMethodChannel(name: "tech.codex.shared_auth/token",
                                       binaryMessenger: engineBridge.applicationRegistrar.messenger())
    channel.setMethodCallHandler({
      [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      if (call.method == "getSharedKeychainGroupId") {
        let teamID = self?.getTeamID() ?? "ABDSDa125"
        result("\(teamID).com.codex.shared")
      } else {
        result(FlutterMethodNotImplemented)
      }
    })
  }

  private func getTeamID() -> String? {
    let query: [String: Any] = [
      kSecClass as String: kSecClassGenericPassword,
      kSecAttrAccount as String: "bundleSeedID",
      kSecAttrService as String: "bundleSeedID",
      kSecReturnAttributes as String: true
    ]
    
    var result: AnyObject?
    var status = SecItemCopyMatching(query as CFDictionary, &result)
    
    if status == errSecItemNotFound {
      status = SecItemAdd(query as CFDictionary, nil)
      if status == errSecSuccess {
        status = SecItemCopyMatching(query as CFDictionary, &result)
      }
    }
    
    if status == errSecSuccess, let dict = result as? [String: Any], let accessGroup = dict[kSecAttrAccessGroup as String] as? String {
      let components = accessGroup.components(separatedBy: ".")
      if !components.isEmpty {
        return components[0]
      }
    }
    return nil
  }
}

3 Flutter Secure Storage Caches

EasyParking Caching Helper

مسار الملف: lib/core/cache/cache_helper.dart

import 'dart:io';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class CacheHelper {
  static const String _keyOnboardingCompleted = 'onboarding_completed';
  static const String _keyFirstLaunch = 'is_first_launch';
  static const String _refreshTokenKey = 'refresh_token';
  static const String _accessTokenKey = 'access_token';
  static const String _darkModeKey = 'is_dark_mode';

  static FlutterSecureStorage? _secureStorageInstance;
  static const _channel = MethodChannel('tech.codex.shared_auth/token');

  static Future<FlutterSecureStorage> _getSecureStorage() async {
    if (_secureStorageInstance != null) return _secureStorageInstance!;

    String groupId = 'ABDCDE124.com.groupName.shared';
    try {
      if (Platform.isIOS) {
        final String? resolvedGroupId = await _channel.invokeMethod<String>('getSharedKeychainGroupId');
        if (resolvedGroupId != null && resolvedGroupId.isNotEmpty) {
          groupId = resolvedGroupId;
          print('CacheHelper: Resolved iOS Shared Keychain Group ID: $groupId');
        }
      }
    } catch (e) {
      print('CacheHelper: Error resolving shared keychain group: $e');
    }

    _secureStorageInstance = FlutterSecureStorage(
      iOptions: IOSOptions(
        groupId: groupId,
      ),
    );
    return _secureStorageInstance!;
  }

  // Save access token to both shared preferences and shared secure keychain
  static Future<void> saveAccessToken(String token) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_accessTokenKey, token);
    try {
      final secureStorage = await _getSecureStorage();
      print('CacheHelper: Writing access_token to shared Keychain...');
      await secureStorage.write(key: _accessTokenKey, value: token);
      print('CacheHelper: Successfully wrote access_token to shared Keychain: "$token"');
    } catch (e) {
      print('CacheHelper: Failed to write access_token to shared Keychain. Error: $e');
    }
  }

  // Get access token (prioritizing shared secure keychain, fallback to shared preferences)
  static Future<String?> getAccessToken() async {
    try {
      final secureStorage = await _getSecureStorage();
      print('CacheHelper: Reading access_token from shared Keychain...');
      final token = await secureStorage.read(key: _accessTokenKey);
      print('CacheHelper: Read access_token from shared Keychain: "$token"');
      if (token != null && token.isNotEmpty) return token;
    } catch (e) {
      print('CacheHelper: Failed to read access_token from shared Keychain. Error: $e');
    }
    print('CacheHelper: Secure keychain access_token is empty/failed, falling back to SharedPreferences...');
    final prefs = await SharedPreferences.getInstance();
    final token = prefs.getString(_accessTokenKey);
    print('CacheHelper: Read access_token from SharedPreferences: "$token"');
    return token;
  }
}

Control App Secure Storage Helper

مسار الملف: lib/core/cache/secure_storage_helper.dart

import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageHelper {
  static const String _accessTokenKey = 'access_token';
  static const String _refreshTokenKey = 'refresh_token';

  static FlutterSecureStorage? _secureStorageInstance;
  static const _channel = MethodChannel('tech.codex.shared_auth/token');

  static Future<FlutterSecureStorage> _getSecureStorage() async {
    if (_secureStorageInstance != null) return _secureStorageInstance!;

    String groupId = 'ABDCDE124.com.groupName.shared';
    try {
      if (Platform.isIOS) {
        final String? resolvedGroupId = await _channel.invokeMethod<String>('getSharedKeychainGroupId');
        if (resolvedGroupId != null && resolvedGroupId.isNotEmpty) {
          groupId = resolvedGroupId;
          print('SecureStorageHelper: Resolved iOS Shared Keychain Group ID: $groupId');
        }
      }
    } catch (e) {
      print('SecureStorageHelper: Error resolving shared keychain group: $e');
    }

    _secureStorageInstance = FlutterSecureStorage(
      iOptions: IOSOptions(
        groupId: groupId,
      ),
    );
    return _secureStorageInstance!;
  }

  // Save access token
  static Future<void> saveAccessToken(String token) async {
    try {
      final secureStorage = await _getSecureStorage();
      print('SecureStorageHelper: Writing access_token to shared Keychain...');
      await secureStorage.write(key: _accessTokenKey, value: token);
      print('SecureStorageHelper: Successfully wrote access_token: "$token"');
    } catch (e) {
      print('SecureStorageHelper: Failed to write access_token. Error: $e');
    }
  }

  // Get access token
  static Future<String?> getAccessToken() async {
    try {
      final secureStorage = await _getSecureStorage();
      print('SecureStorageHelper: Reading access_token from shared Keychain...');
      final token = await secureStorage.read(key: _accessTokenKey);
      print('SecureStorageHelper: Read access_token from shared Keychain: "$token"');
      return token;
    } catch (e) {
      print('SecureStorageHelper: Failed to read access_token from shared Keychain. Error: $e');
      return null;
    }
  }
}

خلاصة التجربة

مشاركة الـ Keychain هي الطريقة المعيارية والآمنة لمشاركة الجلسات (Sessions) والـ Tokens بين تطبيقاتك على iOS. باستخدام معمارية الـ Master Token والـ App Tokens، يمكنك تقديم تجربة تسجيل دخول تلقائي فائقة السلاسة دون التضحية باستقلالية كل تطبيق في تسجيل الخروج، وتفادي حفظ الـ Tokens الهامة في أماكن غير مشفرة كـ App Groups.

Abdalrahman Reda

Written by Abdalrahman Reda

Software Engineer specialized in Flutter and Python Backend development. Writing about technology, coding practices, and creating impactful applications.