main
swift 250 lines 10.4 KB
Raw
1 import Foundation
2
3 #if canImport(FoundationNetworking)
4 import FoundationNetworking
5 #endif
6
7 /// Headless smbCloud Auth client for a tenant auth app.
8 ///
9 /// A faithful Swift port of the canonical `smbcloud-auth-sdk` tenant client
10 /// surface (`login`, `signup`, `me`, `logout`, `remove`, and the Apple provider
11 /// helpers). It performs no UI and has no dependency on AuthenticationServices
12 /// or the Keychain, so it builds on Apple platforms, Linux, Windows, and
13 /// Android — including inside a server.
14 ///
15 /// This client uses confidential ``ClientCredentials`` (`appId` + `appSecret`).
16 /// Construct and use it where the secret is safe (a backend / BFF). For native
17 /// or browser public clients, use ``OIDC`` or the Apple flow, or call your own
18 /// backend.
19 public struct AuthCoreClient: Sendable {
20 public let environment: SmbCloudEnvironment
21 public let credentials: ClientCredentials
22 private let http: HTTPTransport
23
24 public init(environment: SmbCloudEnvironment, credentials: ClientCredentials) {
25 self.environment = environment
26 self.credentials = credentials
27 self.http = HTTPTransport()
28 }
29
30 init(
31 environment: SmbCloudEnvironment,
32 credentials: ClientCredentials,
33 session: URLSession
34 ) {
35 self.environment = environment
36 self.credentials = credentials
37 self.http = HTTPTransport(session: session)
38 }
39
40 // MARK: - Email / password (tenant credential flow)
41
42 /// `POST /v1/client/users/sign_in` — mirrors `login_with_client`.
43 public func login(username: String, password: String) async throws -> AccountStatus {
44 let url = credentials.tenantURL(environment: environment, path: "v1/client/users/sign_in")
45 var request = URLRequest(url: url)
46 request.httpMethod = "POST"
47 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
48 request.setValue("application/json", forHTTPHeaderField: "Accept")
49 request.setValue(credentials.appId, forHTTPHeaderField: "User-agent")
50 request.httpBody = try JSONEncoder().encode(
51 UserEnvelope(user: .init(email: username, password: password))
52 )
53 return try await http.requestLogin(request)
54 }
55
56 /// `POST /v1/client/users` — mirrors `signup_with_client`.
57 public func signup(email: String, password: String) async throws -> SignupResult {
58 let url = credentials.tenantURL(environment: environment, path: "v1/client/users")
59 var request = URLRequest(url: url)
60 request.httpMethod = "POST"
61 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
62 request.setValue("application/json", forHTTPHeaderField: "Accept")
63 request.setValue(credentials.appId, forHTTPHeaderField: "User-agent")
64 request.httpBody = try JSONEncoder().encode(
65 UserEnvelope(user: .init(email: email, password: password))
66 )
67 return try await http.requestJSON(request, as: SignupResult.self)
68 }
69
70 /// `GET /v1/client/me` — mirrors `me_with_client`.
71 ///
72 /// `accessToken` is the value returned in ``AccountStatus/ready(accessToken:)``
73 /// and is sent verbatim as the `Authorization` header (it already carries
74 /// the `Bearer ` prefix).
75 public func me(accessToken: String) async throws -> User {
76 let url = credentials.tenantURL(environment: environment, path: "v1/client/me")
77 var request = URLRequest(url: url)
78 request.httpMethod = "GET"
79 request.setValue(accessToken, forHTTPHeaderField: "Authorization")
80 request.setValue("application/json", forHTTPHeaderField: "Accept")
81 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
82 return try await http.requestJSON(request, as: User.self)
83 }
84
85 /// `DELETE /v1/client/users/sign_out` — mirrors `logout_with_client`.
86 public func logout(accessToken: String) async throws {
87 let url = credentials.tenantURL(environment: environment, path: "v1/client/users/sign_out")
88 var request = URLRequest(url: url)
89 request.httpMethod = "DELETE"
90 request.setValue(accessToken, forHTTPHeaderField: "Authorization")
91 request.setValue("application/json", forHTTPHeaderField: "Accept")
92 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
93 do {
94 try await http.requestVoid(request)
95 } catch {
96 // Mirror the Rust SDK: any non-OK is surfaced as unauthorized.
97 throw SmbCloudError(code: .unauthorized)
98 }
99 }
100
101 /// `DELETE /v1/client/me` — mirrors `remove_with_client`.
102 public func remove(accessToken: String) async throws {
103 let url = credentials.tenantURL(environment: environment, path: "v1/client/me")
104 var request = URLRequest(url: url)
105 request.httpMethod = "DELETE"
106 request.setValue(accessToken, forHTTPHeaderField: "Authorization")
107 request.setValue(credentials.appId, forHTTPHeaderField: "User-agent")
108 try await http.requestVoid(request)
109 }
110
111 // MARK: - Account recovery
112 //
113 // NOTE: these wrap backend endpoints that the canonical `smbcloud-auth-sdk`
114 // crate does not expose yet (only the CLI-internal `smbcloud-auth` crate
115 // does). They're faithful to the backend contract; upstream them to
116 // `smbcloud-auth-sdk` to restore full cross-binding parity. Each returns the
117 // server's user-facing message.
118
119 /// `POST /v1/client/users/reset_password` — sends reset instructions.
120 @discardableResult
121 public func requestPasswordReset(email: String) async throws -> String {
122 try await postMessage(path: "v1/client/users/reset_password", body: EmailEnvelope(user: .init(email: email)))
123 }
124
125 /// `POST /v1/client/users/reset_password/complete` — sets a new password
126 /// using the token from the reset email.
127 @discardableResult
128 public func completePasswordReset(
129 token: String,
130 password: String,
131 passwordConfirmation: String? = nil
132 ) async throws -> String {
133 let body = ResetPasswordComplete(
134 resetPasswordToken: token,
135 password: password,
136 passwordConfirmation: passwordConfirmation ?? password
137 )
138 return try await postMessage(path: "v1/client/users/reset_password/complete", body: body)
139 }
140
141 /// `POST /v1/client/users/resend_confirmation` — re-sends the confirmation email.
142 @discardableResult
143 public func resendConfirmation(email: String) async throws -> String {
144 try await postMessage(path: "v1/client/users/resend_confirmation", body: EmailEnvelope(user: .init(email: email)))
145 }
146
147 private func postMessage<Body: Encodable>(path: String, body: Body) async throws -> String {
148 let url = credentials.tenantURL(environment: environment, path: path)
149 var request = URLRequest(url: url)
150 request.httpMethod = "POST"
151 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
152 request.setValue("application/json", forHTTPHeaderField: "Accept")
153 request.setValue(credentials.appId, forHTTPHeaderField: "User-agent")
154 request.httpBody = try JSONEncoder().encode(body)
155 return try await http.requestJSON(request, as: MessageResponse.self).message
156 }
157
158 // MARK: - Apple provider (tenant)
159
160 /// Builds the tenant Apple authorization request — mirrors
161 /// `apple::build_authorization_request_with_client`.
162 public func buildAppleAuthorizationRequest(
163 redirectURI: String,
164 state: String? = nil
165 ) -> AppleAuthorizationRequest {
166 let resolvedState = state ?? UUID().uuidString
167 let url = credentials.tenantURL(
168 environment: environment,
169 path: "v1/client/oauth/apple/authorize",
170 extraQuery: [
171 URLQueryItem(name: "redirect_uri", value: redirectURI),
172 URLQueryItem(name: "state", value: resolvedState),
173 ]
174 )
175 return AppleAuthorizationRequest(
176 authorizeURL: url,
177 redirectURI: redirectURI,
178 state: resolvedState
179 )
180 }
181
182 /// Parses an Apple callback URL into a session — mirrors `apple::parse_callback_url`.
183 public func parseAppleCallbackURL(
184 _ callbackURL: String,
185 expectedState: String? = nil
186 ) throws -> AppleAuthSession {
187 guard let components = URLComponents(string: callbackURL) else {
188 throw SmbCloudError(code: .parseError, message: "Invalid Apple callback URL.")
189 }
190 let items = components.queryItems ?? []
191 func value(_ name: String) -> String? {
192 items.first { $0.name == name }?.value
193 }
194
195 if let error = value("error") {
196 throw SmbCloudError(code: .invalidParams, message: error)
197 }
198 let state = value("state")
199 if let expectedState, state != expectedState {
200 throw SmbCloudError(code: .invalidParams, message: "Apple callback state mismatch.")
201 }
202 guard let accessToken = value("access_token") else {
203 throw SmbCloudError(code: .invalidParams, message: "Missing `access_token` in Apple callback URL.")
204 }
205 guard let providerAccountId = value("provider_account_id") else {
206 throw SmbCloudError(code: .invalidParams, message: "Missing `provider_account_id` in Apple callback URL.")
207 }
208 return AppleAuthSession(
209 accessToken: accessToken,
210 refreshToken: value("refresh_token"),
211 email: value("email"),
212 name: value("name"),
213 provider: value("provider") ?? "apple",
214 providerAccountId: providerAccountId,
215 state: state
216 )
217 }
218 }
219
220 // MARK: - Request bodies
221
222 private struct UserEnvelope: Encodable {
223 struct Credentials: Encodable {
224 let email: String
225 let password: String
226 }
227 let user: Credentials
228 }
229
230 private struct EmailEnvelope: Encodable {
231 struct EmailOnly: Encodable { let email: String }
232 let user: EmailOnly
233 }
234
235 private struct ResetPasswordComplete: Encodable {
236 let resetPasswordToken: String
237 let password: String
238 let passwordConfirmation: String
239
240 enum CodingKeys: String, CodingKey {
241 case resetPasswordToken = "reset_password_token"
242 case password
243 case passwordConfirmation = "password_confirmation"
244 }
245 }
246
247 /// `{ "message": "…" }` — the recovery endpoints' response envelope.
248 private struct MessageResponse: Decodable {
249 let message: String
250 }