Private
Public Access
1
0
Files
SilverApple/SilverApple/Auth/PassportAuthService.swift
SilverLABS 9073a51787 fix(build): resolve Xcode 26 compilation errors
- 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>
2026-04-05 12:13:04 +01:00

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 }