update
This commit is contained in:
@@ -15,21 +15,13 @@ final class AuthService: NSObject {
|
||||
func login(presentationAnchor: ASPresentationAnchor?) async throws {
|
||||
self.presentationAnchor = presentationAnchor
|
||||
|
||||
let codeVerifier = generateCodeVerifier()
|
||||
let codeChallenge = generateCodeChallenge(from: codeVerifier)
|
||||
|
||||
let session = UUID().uuidString
|
||||
UserDefaults.standard.set(codeVerifier, forKey: "oidc_code_verifier_\(session)")
|
||||
|
||||
// Only the redirect_uri is needed — the backend owns PKCE generation.
|
||||
var components = URLComponents(
|
||||
url: AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.login),
|
||||
resolvingAgainstBaseURL: true
|
||||
)
|
||||
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "session", value: session),
|
||||
URLQueryItem(name: "code_challenge", value: codeChallenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
URLQueryItem(name: "redirect_uri", value: AppConfig.authCallbackURL)
|
||||
]
|
||||
|
||||
@@ -39,6 +31,8 @@ final class AuthService: NSObject {
|
||||
|
||||
let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker"
|
||||
|
||||
// Use a shared (non-ephemeral) session so the backend session cookie set during
|
||||
// /auth/login is automatically included in the /auth/token POST.
|
||||
let webAuthSession = ASWebAuthenticationSession(
|
||||
url: authURL,
|
||||
callbackURLScheme: callbackScheme
|
||||
@@ -71,10 +65,12 @@ final class AuthService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
self?.handleCallback(url: callbackURL, session: session)
|
||||
self?.handleCallback(url: callbackURL)
|
||||
}
|
||||
|
||||
webAuthSession.presentationContextProvider = self
|
||||
// prefersEphemeralWebBrowserSession = false ensures the session cookie from
|
||||
// /auth/login is retained and sent with the subsequent /auth/token request.
|
||||
webAuthSession.prefersEphemeralWebBrowserSession = false
|
||||
|
||||
self.authSession = webAuthSession
|
||||
@@ -91,42 +87,83 @@ final class AuthService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCallback(url: URL, session: String) {
|
||||
private func handleCallback(url: URL) {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
|
||||
let state = components.queryItems?.first(where: { $0.name == "state" })?.value
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authError,
|
||||
object: nil,
|
||||
userInfo: ["error": AuthError.noCallback]
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let codeVerifier = UserDefaults.standard.string(forKey: "oidc_code_verifier_\(session)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authCallbackReceived,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"code": code,
|
||||
"codeVerifier": codeVerifier ?? ""
|
||||
]
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
let tokenResponse = try await exchangeCodeForTokens(
|
||||
code: code,
|
||||
state: state,
|
||||
redirectUri: AppConfig.authCallbackURL
|
||||
)
|
||||
|
||||
await AuthManager.shared.handleTokenResponse(tokenResponse)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .authCallbackReceived, object: nil)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authError,
|
||||
object: nil,
|
||||
userInfo: ["error": AuthError.failed(error.localizedDescription)]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateCodeVerifier() -> String {
|
||||
var buffer = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
|
||||
return Data(buffer).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func generateCodeChallenge(from verifier: String) -> String {
|
||||
guard let data = verifier.data(using: .ascii) else { return "" }
|
||||
let hash = SHA256.hash(data: data)
|
||||
return Data(hash).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
private func exchangeCodeForTokens(
|
||||
code: String,
|
||||
state: String,
|
||||
redirectUri: String
|
||||
) async throws -> TokenResponse {
|
||||
let url = AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.token)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
// code_verifier is intentionally omitted — the backend uses its own verifier
|
||||
// that was generated during /auth/login and stored in the server-side session.
|
||||
// state is sent so the backend can look up and validate the original session.
|
||||
let body: [String: Any] = [
|
||||
"code": code,
|
||||
"state": state,
|
||||
"code_verifier": "", // kept for API compatibility; backend ignores it
|
||||
"redirect_uri": redirectUri
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw AuthError.failed("Invalid response")
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let errorMessage = errorJson["error"] as? String {
|
||||
throw AuthError.failed(errorMessage)
|
||||
}
|
||||
throw AuthError.failed("Token exchange failed with status \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,3 +197,19 @@ extension Notification.Name {
|
||||
static let authCallbackReceived = Notification.Name("authCallbackReceived")
|
||||
static let authError = Notification.Name("authError")
|
||||
}
|
||||
|
||||
struct TokenResponse: Codable {
|
||||
let accessToken: String
|
||||
let idToken: String
|
||||
let tokenType: String
|
||||
let expiresIn: Int?
|
||||
let user: User
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case idToken = "id_token"
|
||||
case tokenType = "token_type"
|
||||
case expiresIn = "expires_in"
|
||||
case user
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user