feat(ios): initial SilverApple Swift project scaffold
- App entry point, AppEnvironment dependency container - PassportAuthService: PKCE OAuth 2.0 against SilverSHELL Passport - TokenStore: Keychain-backed token storage - SilverAPIClient: MDM enrollment + privacy score API calls - MDMEnrollmentService: enrollment URL fetching + device listing - Onboarding coordinator with MDM, account setup, and hardening steps - Dashboard with privacy score ring and category breakdown - WelcomeView with Passport sign-in flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
119
SilverApple/Auth/PassportAuthService.swift
Normal file
119
SilverApple/Auth/PassportAuthService.swift
Normal file
@@ -0,0 +1,119 @@
|
||||
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 = try await withCheckedThrowingContinuation { continuation 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 }
|
||||
Reference in New Issue
Block a user