main
swift 205 lines 8.21 KB
Raw
1 import AuthCore
2 import Foundation
3
4 #if canImport(AuthenticationServices) && (os(iOS) || os(macOS) || os(visionOS))
5 import AuthenticationServices
6
7 public typealias SmbCloudPresentationAnchor = ASPresentationAnchor
8 #endif
9
10 /// Errors specific to the Apple hosted-login UI.
11 public enum SmbCloudWebAuthError: Error, LocalizedError, Sendable {
12 case loginInProgress
13 case cancelled
14 case invalidRedirectURL(String)
15 case stateMismatch
16 case missingCallbackURL
17 case authenticationFailed(String?)
18
19 public var errorDescription: String? {
20 switch self {
21 case .loginInProgress: return "A web authentication session is already in progress."
22 case .cancelled: return "The authentication session was cancelled."
23 case .invalidRedirectURL(let message): return message
24 case .stateMismatch: return "The callback state did not match."
25 case .missingCallbackURL: return "The authentication session completed without a callback URL."
26 case .authenticationFailed(let message): return message ?? "The authentication session failed."
27 }
28 }
29 }
30
31 /// Apple-platform hosted login built on `AuthCore`'s OIDC public-client flow.
32 ///
33 /// `SmbCloudWebAuth` is the **public client** entry point: it uses only an
34 /// `oidcClientId` (no `app_secret`) and drives an `ASWebAuthenticationSession`
35 /// through smbCloud's OIDC Authorization Code + PKCE flow. This is the
36 /// recommended native sign-in path. Email/password sign-in (which needs the
37 /// confidential `app_secret`) belongs on a backend via ``AuthCoreClient``.
38 ///
39 /// For headless or non-Apple usage, use ``OIDC`` (or ``AuthCoreClient``) from
40 /// the `AuthCore` product directly.
41 public final class SmbCloudWebAuth: @unchecked Sendable {
42 public let environment: SmbCloudEnvironment
43 public let oidcClientId: String
44 public let redirectURL: URL
45
46 #if canImport(AuthenticationServices) && (os(iOS) || os(macOS) || os(visionOS))
47 @MainActor private var activeAuthenticationSession: ASWebAuthenticationSession?
48 @MainActor private var activePresentationContextProvider: SmbCloudPresentationContextProvider?
49 #endif
50
51 public init(
52 environment: SmbCloudEnvironment = .production,
53 oidcClientId: String,
54 redirectURL: URL
55 ) {
56 self.environment = environment
57 self.oidcClientId = oidcClientId
58 self.redirectURL = redirectURL
59 }
60
61 /// The callback scheme derived from ``redirectURL``.
62 public var callbackScheme: String? {
63 guard let scheme = redirectURL.scheme, !scheme.isEmpty else { return nil }
64 return scheme
65 }
66
67 // Non-interactive helpers, available wherever the package builds.
68
69 public func userInfo(accessToken: String, tenantId: String? = nil) async throws
70 -> OIDC.UserInfo
71 {
72 try await OIDC.getUserInfo(
73 environment: environment, accessToken: accessToken, tenantId: tenantId)
74 }
75
76 public func userInfo(session: SmbCloudSession, tenantId: String? = nil) async throws
77 -> OIDC.UserInfo
78 {
79 try await userInfo(accessToken: session.accessToken, tenantId: tenantId)
80 }
81
82 public func clearSession(credentialsManager: SmbCloudCredentialsStore? = nil) throws {
83 try credentialsManager?.clear()
84 }
85
86 public func logout(credentialsManager: SmbCloudCredentialsStore? = nil) throws {
87 try clearSession(credentialsManager: credentialsManager)
88 }
89 }
90
91 #if canImport(AuthenticationServices) && (os(iOS) || os(macOS) || os(visionOS))
92 extension SmbCloudWebAuth {
93 /// Presents the hosted OIDC login and returns the resulting session.
94 @MainActor
95 public func login(
96 presentationAnchorProvider: @escaping () -> SmbCloudPresentationAnchor,
97 prefersEphemeralSession: Bool = false,
98 credentialsManager: SmbCloudCredentialsStore? = nil
99 ) async throws -> SmbCloudSession {
100 guard activeAuthenticationSession == nil else {
101 throw SmbCloudWebAuthError.loginInProgress
102 }
103 guard let callbackScheme else {
104 throw SmbCloudWebAuthError.invalidRedirectURL(
105 "The redirect URL must include a callback scheme.")
106 }
107
108 let authRequest = try OIDC.buildAuthorizationRequest(
109 environment: environment,
110 oidcClientId: oidcClientId,
111 redirectURI: redirectURL.absoluteString
112 )
113
114 let presentationContextProvider = SmbCloudPresentationContextProvider(
115 presentationAnchorProvider: presentationAnchorProvider)
116
117 defer {
118 activeAuthenticationSession = nil
119 activePresentationContextProvider = nil
120 }
121
122 let callbackURL = try await withTaskCancellationHandler {
123 try await withCheckedThrowingContinuation {
124 (continuation: CheckedContinuation<URL, Error>) in
125 let session = ASWebAuthenticationSession(
126 url: authRequest.authorizeURL,
127 callbackURLScheme: callbackScheme
128 ) { callbackURL, error in
129 if let error {
130 continuation.resume(throwing: Self.authenticationError(from: error))
131 return
132 }
133 guard let callbackURL else {
134 continuation.resume(throwing: SmbCloudWebAuthError.missingCallbackURL)
135 return
136 }
137 continuation.resume(returning: callbackURL)
138 }
139
140 session.prefersEphemeralWebBrowserSession = prefersEphemeralSession
141 session.presentationContextProvider = presentationContextProvider
142
143 activePresentationContextProvider = presentationContextProvider
144 activeAuthenticationSession = session
145
146 guard session.start() else {
147 activeAuthenticationSession = nil
148 activePresentationContextProvider = nil
149 continuation.resume(
150 throwing: SmbCloudWebAuthError.authenticationFailed(
151 "Failed to start the smbCloud web authentication session."))
152 return
153 }
154 }
155 } onCancel: { [weak self] in
156 guard let self else { return }
157 Task { @MainActor in
158 self.activeAuthenticationSession?.cancel()
159 self.activeAuthenticationSession = nil
160 self.activePresentationContextProvider = nil
161 }
162 }
163
164 let payload = try OIDC.parseCallbackURL(callbackURL.absoluteString)
165 guard payload.state == authRequest.state else {
166 throw SmbCloudWebAuthError.stateMismatch
167 }
168
169 let tokens = try await OIDC.exchangeCode(
170 environment: environment,
171 oidcClientId: oidcClientId,
172 redirectURI: redirectURL.absoluteString,
173 code: payload.code,
174 codeVerifier: authRequest.codeVerifier
175 )
176
177 let session = SmbCloudSession(tokenResponse: tokens)
178 try credentialsManager?.store(session)
179 return session
180 }
181
182 private static func authenticationError(from error: Error) -> SmbCloudWebAuthError {
183 if let authError = error as? ASWebAuthenticationSessionError,
184 authError.code == .canceledLogin
185 {
186 return .cancelled
187 }
188 return .authenticationFailed(error.localizedDescription)
189 }
190 }
191
192 private final class SmbCloudPresentationContextProvider:
193 NSObject, ASWebAuthenticationPresentationContextProviding
194 {
195 private let presentationAnchorProvider: () -> SmbCloudPresentationAnchor
196
197 init(presentationAnchorProvider: @escaping () -> SmbCloudPresentationAnchor) {
198 self.presentationAnchorProvider = presentationAnchorProvider
199 }
200
201 func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
202 presentationAnchorProvider()
203 }
204 }
205 #endif