-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathGithubAuthenticationService.swift
More file actions
277 lines (224 loc) · 10.8 KB
/
GithubAuthenticationService.swift
File metadata and controls
277 lines (224 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
//
// GithubAuthenticationService.swift
// DevLog
//
// Created by opfic on 6/4/25.
//
import AuthenticationServices
import Foundation
import FirebaseAuth
import FirebaseFirestore
import FirebaseFunctions
import FirebaseMessaging
final class GithubAuthenticationService: NSObject, AuthenticationService {
private enum FunctionName: String {
case requestGithubTokens
case revokeGithubAccessToken
}
private let store = Firestore.firestore()
private let functions = Functions.functions(region: "asia-northeast3")
private let messaging = Messaging.messaging()
private var user: User? { Auth.auth().currentUser }
private let providerID = AuthProviderID.gitHub
private let provider = TopViewControllerProvider()
private let logger = Logger(category: "GithubAuthService")
func signIn() async throws -> AuthDataResponse {
logger.info("Starting GitHub sign in")
do {
// 1. GitHub OAuth 로그인 요청
logger.debug("Requesting authorization code")
let authorizationCode = try await requestAuthorizationCode()
// 2. Firebase Functions를 통해 customToken 발급 요청
logger.debug("Requesting tokens from Firebase Function")
let (accessToken, customToken) = try await requestTokens(authorizationCode: authorizationCode)
// 3. Firebase 로그인
logger.debug("Signing in with custom token")
let result = try await Auth.auth().signIn(withCustomToken: customToken)
// 4. Firebase Auth 사용자 프로필 업데이트
let githubUser = try await requestUserProfile(accessToken: accessToken)
if let photoURL = githubUser.avatarURL, let url = URL(string: photoURL) {
let changeRequest = result.user.createProfileChangeRequest()
changeRequest.photoURL = url
changeRequest.displayName = githubUser.name ?? githubUser.login
try await changeRequest.commitChanges()
}
// 5. GitHub 계정과 Firebase Auth 계정 연결
if !result.user.providerData.contains(where: { $0.providerID == "github.com" }) {
let credential = OAuthProvider.credential(providerID: AuthProviderID.gitHub, accessToken: accessToken)
try await result.user.link(with: credential)
}
let fcmToken = try await messaging.token()
logger.info("Successfully signed in with GitHub")
return result.user.makeResponse(
providerID: .gitHub,
fcmToken: fcmToken,
accessToken: accessToken
)
} catch {
logger.error("Failed to sign in with GitHub", error: error)
throw error
}
}
func signOut(_ uid: String) async throws {
do {
let infoRef = store.document(FirestorePath.userData(uid, document: .tokens))
let doc = try await infoRef.getDocument()
if doc.exists {
try await infoRef.updateData(["fcmToken": FieldValue.delete()])
}
try await messaging.deleteToken()
try Auth.auth().signOut()
} catch {
logger.error("Failed to sign out with GitHub", error: error)
throw error
}
}
func deleteAuth(_ uid: String) async throws {
do {
try await revokeAccessToken()
} catch {
logger.error("Failed to delete GitHub auth", error: error)
throw error
}
}
func link(uid: String, email: String) async throws {
logger.info("Linking GitHub account for user: \(uid)")
do {
let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens))
let authorizationCode = try await requestAuthorizationCode()
let (accessToken, _) = try await requestTokens(authorizationCode: authorizationCode)
let githubUser = try await requestUserProfile(accessToken: accessToken)
guard let githubEmail = githubUser.email else {
logger.error("GitHub email not found")
try await revokeAccessToken(accessToken: accessToken)
throw EmailFetchError.emailNotFound
}
if githubEmail != email {
logger.error("Email mismatch - Expected: \(email), Got: \(githubEmail)")
try await revokeAccessToken(accessToken: accessToken)
throw EmailFetchError.emailMismatch
}
try await tokensRef.setData(["githubAccessToken": accessToken], merge: true)
let credential = OAuthProvider.credential(providerID: AuthProviderID.gitHub, accessToken: accessToken)
try await user?.link(with: credential)
logger.info("Successfully linked GitHub account")
} catch {
logger.error("Failed to link GitHub account", error: error)
throw error
}
}
func unlink(_ uid: String) async throws {
do {
logger.info("Starting GitHub access token revocation for unlink. uid: \(uid)")
try await revokeAccessToken()
let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens))
logger.info("Starting GitHub access token deletion from Firestore for unlink. uid: \(uid)")
try await tokensRef.updateData(["githubAccessToken": FieldValue.delete()])
logger.info("Starting Firebase GitHub provider unlink. uid: \(uid)")
_ = try await user?.unlink(fromProvider: providerID.rawValue)
} catch {
logger.error("Failed to unlink GitHub account", error: error)
throw error
}
}
@MainActor
func requestAuthorizationCode() async throws -> String {
guard let clientID = Bundle.main.object(forInfoDictionaryKey: "GITHUB_CLIENT_ID") as? String,
let redirectURL = Bundle.main.object(forInfoDictionaryKey: "APP_REDIRECT_URL") as? String,
let urlComponents = URLComponents(string: redirectURL),
let callbackURLScheme = urlComponents.scheme else {
throw URLError(.badURL)
}
// state: CSRF(사이트 간 요청 위조) 공격 방지용 랜덤 문자열
let state = UUID().uuidString
let scope = "read:user user:email" // 공개된 정보와 이메일 요청
// Use URLComponents for proper encoding
var components = URLComponents(string: "https://github.com/login/oauth/authorize")!
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "scope", value: scope),
URLQueryItem(name: "redirect_url", value: redirectURL),
URLQueryItem(name: "state", value: state)
]
guard let authURL = components.url else {
throw URLError(.badURL)
}
return try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(
url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in
if let error = error {
continuation.resume(throwing: error)
return
}
guard let callbackURL = callbackURL,
let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems,
let code = queryItems.first(where: { $0.name == "code" })?.value else {
continuation.resume(throwing: URLError(.badServerResponse))
return
}
// 반환된 state 값 확인 / 받아온 값이 다르면 CSRF 공격 가능성 있음
guard let returnedState = queryItems.first(where: { $0.name == "state" })?.value,
returnedState == state else {
continuation.resume(throwing: SocialLoginError.invalidOAuthState)
return
}
continuation.resume(returning: code)
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false // 웹에서 깃헙 로그인 후 세션 유지
if !session.start() {
continuation.resume(throwing: SocialLoginError.failedToStartWebAuthenticationSession)
}
}
}
// Firebase Function 호출: Custom Token 발급
func requestTokens(authorizationCode: String) async throws -> (String, String) {
let requestTokenFunction = functions.httpsCallable(FunctionName.requestGithubTokens)
let result = try await requestTokenFunction.call(["code": authorizationCode])
if let data = result.data as? [String: Any],
let accessToken = data["accessToken"] as? String,
let customToken = data["customToken"] as? String {
return (accessToken, customToken)
}
throw URLError(.badServerResponse)
}
func revokeAccessToken(accessToken: String? = nil) async throws {
var param: [String: Any] = [:]
if let accessToken = accessToken {
param["accessToken"] = accessToken
}
let revokeFunction = functions.httpsCallable(FunctionName.revokeGithubAccessToken)
_ = try await revokeFunction.call(param)
}
// GitHub API로 사용자 프로필 정보 가져오기
func requestUserProfile(accessToken: String) async throws -> GitHubUser {
var request = URLRequest(url: URL(string: "https://api.github.com/user")!)
request.httpMethod = "GET"
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.addValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
let decoder = JSONDecoder()
return try decoder.decode(GitHubUser.self, from: data)
}
}
extension GithubAuthenticationService: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return provider.keyWindow() ?? ASPresentationAnchor()
}
struct GitHubUser: Codable {
let login: String
let name: String?
let avatarURL: String?
let email: String?
enum CodingKeys: String, CodingKey {
case login
case name
case avatarURL = "avatar_url"
case email
}
}
}