Implemented complete TCA architecture for iOS and watchOS targets: - Authentication flow (Sign in with Apple + Yahoo OAuth) - OAuth token management with iCloud Key-Value Storage - Yahoo Fantasy Sports API client with async/await - Watch Connectivity for iPhone ↔ Watch data sync - Complete UI for both iPhone and Watch platforms Core features: - Matchup score display - Category breakdown with win/loss/tie indicators - Roster status tracking - Manual refresh functionality - Persistent data caching on Watch Technical stack: - The Composable Architecture for state management - Swift Concurrency (async/await, actors) - WatchConnectivity framework - Sign in with Apple - OAuth 2.0 authentication flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
111 lines
3.2 KiB
Swift
111 lines
3.2 KiB
Swift
//
|
|
// OAuthManager.swift
|
|
// FantasyWatch
|
|
//
|
|
// Created by Claude Code
|
|
//
|
|
|
|
import Foundation
|
|
|
|
actor OAuthManager {
|
|
private let tokenStorage: OAuthTokenStorage
|
|
private let clientID: String
|
|
private let clientSecret: String
|
|
private var refreshTask: Task<TokenPair, Error>?
|
|
private var currentUserID: String?
|
|
|
|
init(
|
|
tokenStorage: OAuthTokenStorage,
|
|
clientID: String,
|
|
clientSecret: String
|
|
) {
|
|
self.tokenStorage = tokenStorage
|
|
self.clientID = clientID
|
|
self.clientSecret = clientSecret
|
|
}
|
|
|
|
func setCurrentUser(_ userID: String) {
|
|
self.currentUserID = userID
|
|
}
|
|
|
|
func validToken() async throws -> String {
|
|
guard let userID = currentUserID else {
|
|
throw OAuthError.authorizationFailed
|
|
}
|
|
|
|
if await tokenStorage.isTokenValid(for: userID) {
|
|
guard let token = await tokenStorage.getAccessToken(for: userID) else {
|
|
throw OAuthError.tokenExpired
|
|
}
|
|
return token
|
|
}
|
|
|
|
let refreshedTokenPair = try await refreshToken()
|
|
return refreshedTokenPair.accessToken
|
|
}
|
|
|
|
func refreshToken() async throws -> TokenPair {
|
|
if let existingTask = refreshTask {
|
|
return try await existingTask.value
|
|
}
|
|
|
|
guard let userID = currentUserID else {
|
|
throw OAuthError.authorizationFailed
|
|
}
|
|
|
|
guard let refreshToken = await tokenStorage.getRefreshToken(for: userID) else {
|
|
throw OAuthError.noRefreshToken
|
|
}
|
|
|
|
let task = Task<TokenPair, Error> {
|
|
let url = URL(string: "https://api.login.yahoo.com/oauth2/get_token")!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
|
|
let bodyParams = [
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refreshToken,
|
|
"client_id": clientID,
|
|
"client_secret": clientSecret
|
|
]
|
|
|
|
let bodyString = bodyParams
|
|
.map { "\($0.key)=\($0.value)" }
|
|
.joined(separator: "&")
|
|
|
|
request.httpBody = bodyString.data(using: .utf8)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
(200...299).contains(httpResponse.statusCode) else {
|
|
throw OAuthError.invalidResponse
|
|
}
|
|
|
|
let tokenResponse = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
|
|
let tokenPair = tokenResponse.toTokenPair()
|
|
|
|
await tokenStorage.saveTokenPair(tokenPair, for: userID)
|
|
|
|
return tokenPair
|
|
}
|
|
|
|
refreshTask = task
|
|
|
|
defer { refreshTask = nil }
|
|
|
|
return try await task.value
|
|
}
|
|
|
|
func saveTokenPair(_ tokenPair: TokenPair) async {
|
|
guard let userID = currentUserID else { return }
|
|
await tokenStorage.saveTokenPair(tokenPair, for: userID)
|
|
}
|
|
|
|
func clearTokens() async {
|
|
guard let userID = currentUserID else { return }
|
|
await tokenStorage.clearTokens(for: userID)
|
|
}
|
|
}
|