Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 45 additions & 27 deletions Data/Sources/Network/Authentication/AccessTokenAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ enum TokenRefreshError: Error {
final class AccessTokenAuthenticator: Authenticator {
typealias Credential = AccessTokenCredential

private let remote: any AuthRemoteDataSourceProtocol
private let tokenRefreshManager: TokenRefreshManager

init(remote: any AuthRemoteDataSourceProtocol = AuthRemoteDataSource(
authProvider: MoyaProvider<AuthAPITarget>.default,
oauthProvider: MoyaProvider<OAuthAPITarget>.default
)) {
self.remote = remote
self.tokenRefreshManager = TokenRefreshManager(remote: remote)
}

func apply(_ credential: Credential, to urlRequest: inout URLRequest) {
Expand All @@ -37,8 +37,12 @@ final class AccessTokenAuthenticator: Authenticator {
completion: @escaping @Sendable (Result<Credential, any Error>) -> Void
) {
_Concurrency.Task {
let result = await refreshCredential(credential)
completion(result)
do {
let refreshedCredential = try await tokenRefreshManager.refreshCredentialIfNeeded(current: credential)
completion(.success(refreshedCredential))
} catch {
completion(.failure(error))
}
}
}

Expand All @@ -47,7 +51,13 @@ final class AccessTokenAuthenticator: Authenticator {
with response: HTTPURLResponse,
failDueToAuthenticationError error: any Error
) -> Bool {
response.statusCode == 401
// First check HTTP status code
if response.statusCode == 401 {
return true
}

// Enhanced 401 detection logic (sync version)
return isRefreshTokenExpiredError(error)
}

func isRequest(
Expand All @@ -58,33 +68,41 @@ final class AccessTokenAuthenticator: Authenticator {
}
}

// MARK: - Enhanced Error Detection
private extension AccessTokenAuthenticator {
func refreshCredential(_ credential: Credential) async -> Result<Credential, any Error> {
guard credential.refreshToken.isEmpty == false else {
return .failure(TokenRefreshError.missingRefreshToken)
/// Enhanced 401 detection with multiple layers (sync version)
func isRefreshTokenExpiredError(_ error: Error) -> Bool {
// 1. Check string description for "statusCodeError(401)"
if String(describing: error).contains("statusCodeError(401)") {
return true
}

do {
let result = try await remote.refresh(token: credential.refreshToken)
let tokens = result.token

KeychainManager.shared.saveTokens(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken
)

guard
let refreshedCredential = AccessTokenCredential.make(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? ""
)
else {
return .failure(TokenRefreshError.invalidAccessToken)
// 2. Check Moya MoyaError.statusCode
if let moyaError = error as? MoyaError {
switch moyaError {
case .statusCode(let response) where response.statusCode == 401:
return true
case .underlying(_, let response) where response?.statusCode == 401:
return true
default:
break
}
}

return .success(refreshedCredential)
} catch {
return .failure(error)
// 3. Check AuthError.isTokenExpiredError
if let authError = error as? AuthError {
return authError.isTokenExpiredError
}

// 4. Check error message keywords
let errorDesc = error.localizedDescription.lowercased()
if errorDesc.contains("401") ||
errorDesc.contains("unauthorized") ||
errorDesc.contains("유효하지 않은 토큰") {
return true
}

return false
}
}

16 changes: 14 additions & 2 deletions Data/Sources/Network/Authentication/AuthSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ final class AuthSessionManager {
public func clear() {
interceptor.credential = nil
}

func updateCredential(accessToken: String, refreshToken: String) {
guard let credential = AccessTokenCredential.make(
accessToken: accessToken,
refreshToken: refreshToken
) else {
interceptor.credential = nil
return
}

interceptor.credential = credential
}
}

private extension AuthSessionManager {
Expand Down Expand Up @@ -84,8 +96,8 @@ private extension AuthSessionManager {

static func loadCredentialFromKeychain() -> AccessTokenCredential? {
guard
let accessToken = KeychainManager.shared.loadAccessToken(),
let refreshToken = KeychainManager.shared.loadRefreshToken()
let accessToken = KeychainManager.live.loadAccessTokenSync(),
let refreshToken = KeychainManager.live.loadRefreshTokenSync()
else {
return nil
}
Expand Down
139 changes: 139 additions & 0 deletions Data/Sources/Network/Authentication/TokenRefreshManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// TokenRefreshManager.swift
// Data
//
// Created by Wonji Suh on 02/09/26.
//

import Foundation
import Alamofire
import Domain
import Moya

/// Actor-based token refresh manager for concurrency safety
/// Prevents multiple simultaneous token refresh attempts
actor TokenRefreshManager {
private var isRefreshing = false
private let remote: any AuthRemoteDataSourceProtocol

init(remote: any AuthRemoteDataSourceProtocol = AuthRemoteDataSource(
authProvider: MoyaProvider<AuthAPITarget>.default,
oauthProvider: MoyaProvider<OAuthAPITarget>.default
)) {
self.remote = remote
}

/// Safely refreshes credential with concurrency protection
/// If already refreshing, yields and retries
func refreshCredentialIfNeeded(current credential: AccessTokenCredential) async throws -> AccessTokenCredential {
// If already refreshing, yield and retry
if isRefreshing {
await _Concurrency.Task.yield()
return try await refreshCredentialIfNeeded(current: credential)
}

// Set refreshing flag
isRefreshing = true
defer { isRefreshing = false }

return try await performRefresh(credential)
}

/// Checks if error indicates refresh token expiration
func isRefreshTokenExpiredError(_ error: Error) -> Bool {
// 1. Check string description for "statusCodeError(401)"
if String(describing: error).contains("statusCodeError(401)") {
return true
}

// 2. Check Moya MoyaError.statusCode
if let moyaError = error as? MoyaError {
switch moyaError {
case .statusCode(let response) where response.statusCode == 401:
return true
case .underlying(_, let response) where response?.statusCode == 401:
return true
default:
break
}
}

// 3. Check AuthError.isTokenExpiredError
if let authError = error as? AuthError {
return authError.isTokenExpiredError
}

// 4. Check error message keywords
let errorDesc = error.localizedDescription.lowercased()
if errorDesc.contains("401") ||
errorDesc.contains("unauthorized") ||
errorDesc.contains("유효하지 않은 토큰") {
return true
}

return false
}
}

// MARK: - Private Methods
private extension TokenRefreshManager {
func performRefresh(_ credential: AccessTokenCredential) async throws -> AccessTokenCredential {
guard !credential.refreshToken.isEmpty else {
throw TokenRefreshError.missingRefreshToken
}

do {
let result = try await remote.refresh(token: credential.refreshToken)
let tokens = result.token

// Save to keychain
await KeychainManager.live.saveTokens(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken
)

// Update session manager credential
await MainActor.run {
AuthSessionManager.shared.updateCredential(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? ""
)
}

guard let refreshedCredential = AccessTokenCredential.make(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? ""
) else {
throw TokenRefreshError.invalidAccessToken
}

return refreshedCredential

} catch {
// If refresh token is expired, perform automatic logout
if isRefreshTokenExpiredError(error) {
await performAutomaticLogout()
}
throw error
}
}

func performAutomaticLogout() async {
// 1. Clear keychain
await KeychainManager.live.clearAll()

// 2. Clear session manager credential
await MainActor.run {
AuthSessionManager.shared.clear()
}

// 3. Send notification for automatic logout
await MainActor.run {
NotificationCenter.default.post(
name: .refreshTokenExpired,
object: nil,
userInfo: ["reason": "401_refresh_failed"]
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch
static let sessionId = "sessionId"
}

public init() {}
private let keychainManager: KeychainManaging

public init(keychainManager: KeychainManaging = KeychainManager.live) {
self.keychainManager = keychainManager
}

public func save(tokens: AuthTokens, socialType: SocialType?, userId: String?) async {
KeychainManager.shared.saveTokens(
await keychainManager.saveTokens(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken
)
Expand All @@ -37,7 +41,7 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch
}

public func loadTokens() async -> AuthTokens? {
let tokens = KeychainManager.shared.loadTokens()
let tokens = await keychainManager.loadTokens()
guard let access = tokens.accessToken,
let refresh = tokens.refreshToken
else { return nil }
Expand All @@ -61,7 +65,7 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch
}

public func clearAll() async {
KeychainManager.shared.clearAll()
await keychainManager.clearAll()
UserDefaults.standard.removeObject(forKey: Keys.sessionId)
UserDefaults.standard.removeObject(forKey: Keys.socialType)
UserDefaults.standard.removeObject(forKey: Keys.userId)
Expand Down
12 changes: 6 additions & 6 deletions Data/Sources/TargetType/Common/Extension+APIHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ import Domain
public extension APIHeaders {
static let registerKeychainTokenProvider: Void = {
APIHeaders.setTokenProvider {
KeychainManager.shared.loadAccessToken()
KeychainManager.live.loadAccessTokenSync()
}
}()

static var accessTokenHeader: [String: String] {
_ = registerKeychainTokenProvider
guard let token = KeychainManager.shared.loadAccessToken() else {

guard let token = KeychainManager.live.loadAccessTokenSync() else {
return ["Content-Type": "application/json"]
}

return [
"Content-Type": "application/json",
"Authorization": "Bearer \(token)"
Expand All @@ -37,4 +37,4 @@ public extension BaseTargetType where Domain == SseuDamDomain {
? APIHeaders.authorizedOrCached
: APIHeaders.cached
}
}
}
16 changes: 16 additions & 0 deletions Domain/Sources/Error/AuthError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public enum AuthError: Error, Equatable, LocalizedError, Hashable {
case backendError(String)
/// 약관 동의가 필요한 경우
case needsTermsAgreement(String)
/// 리프레시 토큰이 만료된 경우
case refreshTokenExpired
/// 그 외 알 수 없는 에러
case unknownError(String)

Expand All @@ -47,8 +49,22 @@ public enum AuthError: Error, Equatable, LocalizedError, Hashable {
return "서버에서 오류가 발생했습니다: \(message)"
case .needsTermsAgreement(let message):
return "\(message)"
case .refreshTokenExpired:
return "인증이 만료되어 다시 로그인이 필요합니다."
case .unknownError(let message):
return "알 수 없는 오류가 발생했습니다: \(message)"
}
}

// MARK: - Token Expiration Helper

/// Indicates whether this error represents a token expiration
public var isTokenExpiredError: Bool {
switch self {
case .refreshTokenExpired:
return true
default:
return false
}
}
}
Loading