- Add ServerConfigView stub (referenced in SilverAppleApp.swift) - Add HardeningListView stub (referenced in DashboardView.swift) - Fix .accentColor → Color.accentColor (ShapeStyle removed member) - Fix withCheckedThrowingContinuation explicit type annotation - Make AppEnvironment.serverUrl internal for ServerConfigView access Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
120 lines
5.1 KiB
Swift
120 lines
5.1 KiB
Swift
import AuthenticationServices
|
|
import CryptoKit
|
|
import Foundation
|
|
|
|
/// PKCE OAuth 2.0 auth against SilverSHELL Passport for SilverApple.
|
|
/// Identical pattern to SilverVPN.Client.iOS/Auth/PassportAuthService.swift.
|
|
/// Scopes: openid profile email caldav carddav mdm silvervpn offline_access
|
|
@MainActor
|
|
final class PassportAuthService: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding {
|
|
|
|
@Published private(set) var isAuthenticated = false
|
|
|
|
private let serverUrl: String
|
|
private let clientId: String
|
|
private let tokenStore: TokenStore
|
|
private var pendingVerifier: String?
|
|
|
|
init(serverUrl: String, tokenStore: TokenStore, clientId: String) {
|
|
self.serverUrl = serverUrl
|
|
self.clientId = clientId
|
|
self.tokenStore = tokenStore
|
|
}
|
|
|
|
func signIn() async throws {
|
|
let verifier = generateCodeVerifier()
|
|
let challenge = generateCodeChallenge(from: verifier)
|
|
pendingVerifier = verifier
|
|
|
|
let authUrl = buildAuthURL(challenge: challenge)
|
|
|
|
let callbackUrl: URL = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, any Error>) in
|
|
let session = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: "silverapple") { url, error in
|
|
if let error { continuation.resume(throwing: error); return }
|
|
guard let url else { continuation.resume(throwing: AuthError.noCallback); return }
|
|
continuation.resume(returning: url)
|
|
}
|
|
session.presentationContextProvider = self
|
|
session.prefersEphemeralWebBrowserSession = true
|
|
session.start()
|
|
}
|
|
|
|
try await exchangeCode(from: callbackUrl)
|
|
}
|
|
|
|
func handleCallback(url: URL) { /* handled by ASWebAuthenticationSession */ }
|
|
|
|
func signOut() {
|
|
tokenStore.clearAll()
|
|
isAuthenticated = false
|
|
}
|
|
|
|
func restoreSession() async {
|
|
guard tokenStore.accessToken != nil else { return }
|
|
isAuthenticated = true
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func exchangeCode(from callbackUrl: URL) async throws {
|
|
guard let code = URLComponents(url: callbackUrl, resolvingAgainstBaseURL: false)?
|
|
.queryItems?.first(where: { $0.name == "code" })?.value,
|
|
let verifier = pendingVerifier else { throw AuthError.noCode }
|
|
pendingVerifier = nil
|
|
|
|
let tokenUrl = URL(string: "\(serverUrl)/oauth/token")!
|
|
var request = URLRequest(url: tokenUrl)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
let body = [
|
|
"grant_type": "authorization_code", "code": code,
|
|
"client_id": clientId,
|
|
"redirect_uri": "silverapple://oauth/callback",
|
|
"code_verifier": verifier
|
|
].map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" }
|
|
.joined(separator: "&")
|
|
request.httpBody = body.data(using: .utf8)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw AuthError.tokenExchangeFailed }
|
|
|
|
let tokens = try JSONDecoder().decode(TokenResponse.self, from: data)
|
|
tokenStore.save(accessToken: tokens.access_token, refreshToken: tokens.refresh_token)
|
|
isAuthenticated = true
|
|
}
|
|
|
|
private func generateCodeVerifier() -> String {
|
|
var b = [UInt8](repeating: 0, count: 32); _ = SecRandomCopyBytes(kSecRandomDefault, b.count, &b)
|
|
return Data(b).base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
}
|
|
|
|
private func generateCodeChallenge(from v: String) -> String {
|
|
Data(SHA256.hash(data: Data(v.utf8))).base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
}
|
|
|
|
private func buildAuthURL(challenge: String) -> URL {
|
|
var c = URLComponents(string: "\(serverUrl)/oauth/authorize")!
|
|
c.queryItems = [
|
|
URLQueryItem(name: "response_type", value: "code"),
|
|
URLQueryItem(name: "client_id", value: clientId),
|
|
URLQueryItem(name: "redirect_uri", value: "silverapple://oauth/callback"),
|
|
URLQueryItem(name: "scope", value: "openid profile email caldav carddav mdm silvervpn offline_access"),
|
|
URLQueryItem(name: "code_challenge", value: challenge),
|
|
URLQueryItem(name: "code_challenge_method", value: "S256")
|
|
]
|
|
return c.url!
|
|
}
|
|
|
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
.flatMap { $0.windows }.first { $0.isKeyWindow } ?? ASPresentationAnchor()
|
|
}
|
|
}
|
|
|
|
struct TokenResponse: Codable { let access_token: String; let refresh_token: String? }
|
|
enum AuthError: Error { case noCallback, noCode, tokenExchangeFailed }
|