session-management
2
总安装量
6
周安装量
#66391
全站排名
安装命令
npx skills add https://github.com/kaakati/rails-enterprise-dev --skill session-management
Agent 安装分布
antigravity
5
claude-code
5
opencode
5
codex
5
windsurf
4
Skill 文档
Session Management â Expert Decisions
Expert decision frameworks for session management choices. Claude knows Keychain basics and OAuth concepts â this skill provides judgment calls for security levels, refresh strategies, and cleanup requirements.
Decision Trees
Token Storage Strategy
Where should you store authentication tokens?
ââ Access token (short-lived, <1hr)
â ââ Keychain with kSecAttrAccessibleAfterFirstUnlock
â Available after first unlock, survives restart
â
ââ Refresh token (long-lived)
â ââ Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly
â More secure, device-bound, requires unlock
â
ââ Session ID (server-side session)
â ââ Keychain with kSecAttrAccessibleAfterFirstUnlock
â Needs to work for background refreshes
â
ââ Temporary auth code (OAuth flow)
â ââ Memory only (no persistence)
â Used once, discarded immediately
â
ââ Remember me preference
ââ UserDefaults (not sensitive)
Just a boolean, not a credential
The trap: Storing tokens in UserDefaults. It’s unencrypted, backed up to iCloud, and readable by jailbroken devices.
Token Refresh Architecture
How should you handle token refresh?
ââ Simple app, few API calls
â ââ Refresh on 401 response
â Reactive: refresh when expired
â
ââ Frequent API calls
â ââ Proactive refresh before expiration
â Schedule refresh 5 min before exp
â
ââ Real-time features (WebSocket)
â ââ Background refresh + reconnect
â Maintain connection continuity
â
ââ Offline-first app
â ââ Longer token lifetime + retry queue
â Queue requests when offline
â
ââ High-security app
ââ Short tokens + frequent refresh
Minimize exposure window
Multi-Session Architecture
How many sessions does your app support?
ââ Single device, single account
â ââ Simple SessionManager singleton
â Replace tokens on new login
â
ââ Single device, multiple accounts (switching)
â ââ Account-keyed Keychain storage
â Keychain items per account ID
â Active account pointer
â
ââ Multiple devices, single account
â ââ Server-side session management
â Device tokens registered with server
â Remote logout capability
â
ââ Multiple devices, multiple accounts
ââ Full session registry
Server tracks all device-account pairs
Cross-device session visibility
Logout Cleanup Scope
What needs clearing on logout?
ââ Always clear
â ââ Tokens (Keychain)
â ââ User object (memory)
â ââ Authenticated state
â
ââ Usually clear
â ââ URL cache (cached API responses)
â ââ HTTP cookies
â ââ User preferences tied to account
â
ââ Consider clearing
â ââ Downloaded files (if user-specific)
â ââ Core Data (if user-specific)
â ââ Image cache (if contains private content)
â
ââ Usually keep
ââ App preferences (theme, language)
ââ Onboarding completion state
ââ Device registration
NEVER Do
Token Storage
NEVER store tokens in UserDefaults:
// â Unencrypted, backed up, exposed on jailbreak
UserDefaults.standard.set(accessToken, forKey: "accessToken")
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
// â
Use Keychain
try KeychainHelper.shared.save(accessToken, service: "auth", account: "accessToken")
try KeychainHelper.shared.save(refreshToken, service: "auth", account: "refreshToken")
NEVER log or print tokens:
// â Tokens in console logs â security disaster
print("Token: \(accessToken)")
Logger.debug("Refresh token: \(refreshToken)")
// â
Log safely
Logger.debug("Token refreshed successfully") // No token content
Logger.debug("Token length: \(accessToken.count)") // Metadata only
NEVER hardcode secrets:
// â Secrets in binary â extractable
let clientSecret = "abc123xyz789"
let apiKey = "sk-live-xxxxx"
// â
Use environment or server
// Fetch from server during OAuth flow
// Or use Info.plist with .gitignore for dev keys
let clientId = Bundle.main.infoDictionary?["CLIENT_ID"] as? String
Token Refresh
NEVER retry refresh infinitely:
// â Infinite loop if refresh token is invalid
func refreshToken() async throws {
do {
let response = try await API.refresh(token: refreshToken)
storeTokens(response)
} catch {
try await refreshToken() // Recursive retry â infinite loop!
}
}
// â
Limited retries with backoff, then logout
func refreshToken(attempt: Int = 0) async throws {
guard attempt < 3 else {
await MainActor.run { logout() }
throw SessionError.refreshFailed
}
do {
let response = try await API.refresh(token: refreshToken)
storeTokens(response)
} catch {
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
try await refreshToken(attempt: attempt + 1)
}
}
NEVER refresh on every request:
// â Unnecessary API calls
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
try await refreshAccessToken() // Refresh EVERY request!
return try await performRequest(endpoint)
}
// â
Refresh only when needed (expired or 401)
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
if isTokenExpired() {
try await refreshAccessToken()
}
let (data, response) = try await performRequest(endpoint)
if (response as? HTTPURLResponse)?.statusCode == 401 {
try await refreshAccessToken()
return try await performRequest(endpoint).0
}
return data
}
Logout
NEVER forget to clear sensitive data:
// â Partial cleanup â tokens still accessible
func logout() {
currentUser = nil
isAuthenticated = false
// Forgot to clear Keychain tokens!
}
// â
Complete cleanup
func logout() {
// Clear tokens
KeychainHelper.shared.deleteAll(service: keychainService)
// Clear memory
currentUser = nil
isAuthenticated = false
// Clear caches
URLCache.shared.removeAllCachedResponses()
// Clear cookies
HTTPCookieStorage.shared.removeCookies(since: .distantPast)
// Clear UserDefaults user data
let userKeys = ["userId", "userEmail", "userPreferences"]
userKeys.forEach { UserDefaults.standard.removeObject(forKey: $0) }
}
NEVER leave background tasks running after logout:
// â Background refresh continues for logged-out user
func logout() {
clearTokens()
currentUser = nil
// Background refresh timer still running!
}
// â
Cancel all background work
func logout() {
// Cancel scheduled tasks
sessionRefreshTask?.cancel()
sessionRefreshTask = nil
// Cancel any pending requests
URLSession.shared.getAllTasks { tasks in
tasks.forEach { $0.cancel() }
}
// Clear data
clearTokens()
currentUser = nil
}
Keychain Security
NEVER use wrong accessibility level:
// â Too permissive â accessible even when locked
kSecAttrAccessibleAlways // Deprecated and insecure!
kSecAttrAccessibleAlwaysThisDeviceOnly // Still too permissive
// â
Appropriate accessibility
// For tokens that need background access:
kSecAttrAccessibleAfterFirstUnlock
// For highly sensitive data (biometric):
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
NEVER ignore Keychain errors:
// â Silent failure â user appears logged out
func getToken() -> String? {
let query = [...]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result) // Ignoring status!
return result as? String
}
// â
Handle errors properly
func getToken() throws -> String? {
let query = [...]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
throw KeychainError.invalidData
}
return token
case errSecItemNotFound:
return nil // No token stored
default:
throw KeychainError.unableToRetrieve(status: status)
}
}
Essential Patterns
Secure SessionManager
@MainActor
final class SessionManager: ObservableObject {
static let shared = SessionManager()
@Published private(set) var isAuthenticated = false
@Published private(set) var currentUser: User?
private let keychainService = "com.app.auth"
private var refreshTask: Task<Void, Never>?
private init() {
restoreSession()
}
// MARK: - Authentication
func login(email: String, password: String) async throws {
let response = try await AuthAPI.login(email: email, password: password)
try storeTokens(access: response.accessToken, refresh: response.refreshToken)
currentUser = response.user
isAuthenticated = true
scheduleTokenRefresh()
}
func logout() {
// Cancel background work
refreshTask?.cancel()
refreshTask = nil
// Clear Keychain
KeychainHelper.shared.deleteAll(service: keychainService)
// Clear state
currentUser = nil
isAuthenticated = false
// Clear caches
URLCache.shared.removeAllCachedResponses()
HTTPCookieStorage.shared.removeCookies(since: .distantPast)
}
// MARK: - Token Management
func getAccessToken() -> String? {
KeychainHelper.shared.read(service: keychainService, account: "accessToken")
}
func refreshAccessToken() async throws {
guard let refreshToken = KeychainHelper.shared.read(
service: keychainService, account: "refreshToken"
) else {
throw SessionError.noRefreshToken
}
let response = try await AuthAPI.refresh(token: refreshToken)
try storeTokens(access: response.accessToken, refresh: response.refreshToken)
}
// MARK: - Private
private func storeTokens(access: String, refresh: String) throws {
try KeychainHelper.shared.save(access, service: keychainService, account: "accessToken")
try KeychainHelper.shared.save(refresh, service: keychainService, account: "refreshToken")
}
private func restoreSession() {
guard let _ = getAccessToken() else { return }
isAuthenticated = true
Task { try? await loadUserProfile() }
}
private func scheduleTokenRefresh() {
refreshTask?.cancel()
refreshTask = Task {
while !Task.isCancelled {
// Refresh 5 minutes before expiration
try? await Task.sleep(nanoseconds: 55 * 60 * 1_000_000_000) // 55 min
guard !Task.isCancelled else { return }
do {
try await refreshAccessToken()
} catch {
await MainActor.run { logout() }
return
}
}
}
}
}
Secure KeychainHelper
final class KeychainHelper {
static let shared = KeychainHelper()
private init() {}
func save(_ value: String, service: String, account: String) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.invalidData
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
// Delete existing
SecItemDelete(query as CFDictionary)
// Add new
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status: status)
}
}
func read(service: String, account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
func delete(service: String, account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(query as CFDictionary)
}
func deleteAll(service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
SecItemDelete(query as CFDictionary)
}
}
enum KeychainError: LocalizedError {
case invalidData
case saveFailed(status: OSStatus)
case readFailed(status: OSStatus)
var errorDescription: String? {
switch self {
case .invalidData: return "Invalid data format"
case .saveFailed(let status): return "Keychain save failed: \(status)"
case .readFailed(let status): return "Keychain read failed: \(status)"
}
}
}
Auto-Retry Network Client
actor NetworkClient {
private let sessionManager: SessionManager
init(sessionManager: SessionManager = .shared) {
self.sessionManager = sessionManager
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var request = try endpoint.asURLRequest()
// Add token
if let token = await sessionManager.getAccessToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request)
// Handle 401 with retry
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 {
try await sessionManager.refreshAccessToken()
// Retry with new token
if let newToken = await sessionManager.getAccessToken() {
request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
let (retryData, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(T.self, from: retryData)
}
}
return try JSONDecoder().decode(T.self, from: data)
}
}
Quick Reference
Keychain Accessibility Levels
| Level | When Accessible | Use For |
|---|---|---|
| WhenUnlocked | Device unlocked | Foreground-only tokens |
| AfterFirstUnlock | After first unlock | Background refresh tokens |
| WhenUnlockedThisDeviceOnly | Unlocked, no backup | Highly sensitive data |
| WhenPasscodeSetThisDeviceOnly | Passcode set | Biometric-protected |
Logout Cleanup Checklist
| Data | Storage | Clear On Logout? |
|---|---|---|
| Access token | Keychain | â Always |
| Refresh token | Keychain | â Always |
| User profile | Memory | â Always |
| API cache | URLCache | â Usually |
| Cookies | HTTPCookieStorage | â Usually |
| User preferences | UserDefaults | â ï¸ Maybe |
| Downloaded files | FileManager | â ï¸ If user-specific |
| App settings | UserDefaults | â Usually keep |
Token Refresh Strategies
| Strategy | When to Use | Implementation |
|---|---|---|
| On 401 | Simple apps | Retry after refresh |
| Proactive | Frequent API calls | Timer before expiration |
| Background | Real-time features | BGAppRefreshTask |
Red Flags
| Smell | Problem | Fix |
|---|---|---|
| Tokens in UserDefaults | Unencrypted storage | Use Keychain |
| Logging token values | Security exposure | Log metadata only |
| Infinite refresh retry | DoS on invalid token | Limited retries + logout |
| Refresh on every request | Unnecessary API calls | Check expiration first |
| Partial logout cleanup | Data leakage | Clear all sensitive data |
| Ignoring Keychain errors | Silent failures | Handle status codes |
| kSecAttrAccessibleAlways | Too permissive | Use AfterFirstUnlock |
| Background tasks after logout | Stale operations | Cancel on logout |