main
swift 181 lines 6.9 KB
Raw
1 import Foundation
2
3 #if canImport(FoundationNetworking)
4 import FoundationNetworking
5 #endif
6
7 #if canImport(CryptoKit)
8 import CryptoKit
9 #else
10 import Crypto
11 #endif
12
13 /// OpenID Connect Authorization Code + PKCE flow (public client).
14 ///
15 /// A faithful port of the canonical SDK's `oidc.rs`. Unlike the tenant
16 /// credential flow, this is a **public client**: it uses only an
17 /// `oidcClientId` — no `app_secret` — so it's appropriate for native and
18 /// browser apps. Drive the user agent yourself (or via `SmbCloudWebAuth` on
19 /// Apple) and feed the callback URL back into ``parseCallbackURL(_:)``.
20 public enum OIDC {
21 /// A prepared OIDC authorization request. Open ``authorizeURL`` in a browser.
22 public struct AuthorizationRequest: Sendable, Equatable {
23 public let authorizeURL: URL
24 public let redirectURI: String
25 public let state: String
26 public let codeVerifier: String
27 }
28
29 /// The `code` + `state` extracted from a redirect callback.
30 public struct CallbackPayload: Sendable, Equatable {
31 public let code: String
32 public let state: String
33 }
34
35 /// The `oauth/token` response.
36 public struct TokenResponse: Codable, Sendable, Equatable {
37 public let accessToken: String
38 public let tokenType: String
39 public let expiresIn: Int?
40 public let refreshToken: String?
41 public let scope: String?
42 public let idToken: String?
43
44 enum CodingKeys: String, CodingKey {
45 case accessToken = "access_token"
46 case tokenType = "token_type"
47 case expiresIn = "expires_in"
48 case refreshToken = "refresh_token"
49 case scope
50 case idToken = "id_token"
51 }
52 }
53
54 /// The `oauth/userinfo` response.
55 public struct UserInfo: Codable, Sendable, Equatable {
56 public let sub: String
57 public let email: String?
58 public let emailVerified: Bool?
59 public let tenantId: UInt64?
60 public let tenantSlug: String?
61
62 enum CodingKeys: String, CodingKey {
63 case sub, email
64 case emailVerified = "email_verified"
65 case tenantId = "tenant_id"
66 case tenantSlug = "tenant_slug"
67 }
68 }
69
70 static let scopes = "openid profile email"
71
72 /// Mirrors `oidc::build_authorization_request`.
73 public static func buildAuthorizationRequest(
74 environment: SmbCloudEnvironment,
75 oidcClientId: String,
76 redirectURI: String
77 ) throws -> AuthorizationRequest {
78 let codeVerifier = UUID().uuidString.replacingOccurrences(of: "-", with: "")
79 + UUID().uuidString.replacingOccurrences(of: "-", with: "")
80 let codeChallenge = base64URLNoPad(Data(SHA256.hash(data: Data(codeVerifier.utf8))))
81 let state = UUID().uuidString
82
83 guard
84 var components = URLComponents(
85 url: environment.baseURL.appendingPathComponent("v1/client/oauth/authorize"),
86 resolvingAgainstBaseURL: false
87 )
88 else {
89 throw SmbCloudError(code: .parseError, message: "Failed to build the authorize URL.")
90 }
91 components.queryItems = [
92 URLQueryItem(name: "client_id", value: oidcClientId),
93 URLQueryItem(name: "redirect_uri", value: redirectURI),
94 URLQueryItem(name: "response_type", value: "code"),
95 URLQueryItem(name: "scope", value: scopes),
96 URLQueryItem(name: "state", value: state),
97 URLQueryItem(name: "code_challenge", value: codeChallenge),
98 URLQueryItem(name: "code_challenge_method", value: "S256"),
99 ]
100 guard let url = components.url else {
101 throw SmbCloudError(code: .parseError, message: "Failed to build the authorize URL.")
102 }
103 return AuthorizationRequest(
104 authorizeURL: url,
105 redirectURI: redirectURI,
106 state: state,
107 codeVerifier: codeVerifier
108 )
109 }
110
111 /// Mirrors `oidc::parse_callback_url`.
112 public static func parseCallbackURL(_ callbackURL: String) throws -> CallbackPayload {
113 guard let components = URLComponents(string: callbackURL) else {
114 throw SmbCloudError(code: .parseError, message: "The callback URL was not valid.")
115 }
116 let items = components.queryItems ?? []
117 let code = items.first { $0.name == "code" }?.value
118 let state = items.first { $0.name == "state" }?.value
119 guard let code, let state else {
120 throw SmbCloudError(code: .invalidParams, message: "Missing authorization code or state.")
121 }
122 return CallbackPayload(code: code, state: state)
123 }
124
125 /// Mirrors `oidc::exchange_code`.
126 public static func exchangeCode(
127 environment: SmbCloudEnvironment,
128 oidcClientId: String,
129 redirectURI: String,
130 code: String,
131 codeVerifier: String,
132 session: URLSession = .shared
133 ) async throws -> TokenResponse {
134 var request = URLRequest(
135 url: environment.baseURL.appendingPathComponent("v1/client/oauth/token"))
136 request.httpMethod = "POST"
137 request.setValue("application/json", forHTTPHeaderField: "Accept")
138 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
139 request.httpBody = formEncoded([
140 ("grant_type", "authorization_code"),
141 ("client_id", oidcClientId),
142 ("code", code),
143 ("redirect_uri", redirectURI),
144 ("code_verifier", codeVerifier),
145 ])
146 return try await HTTPTransport(session: session).requestJSON(request, as: TokenResponse.self)
147 }
148
149 /// Mirrors `oidc::get_userinfo`.
150 public static func getUserInfo(
151 environment: SmbCloudEnvironment,
152 accessToken: String,
153 tenantId: String? = nil,
154 session: URLSession = .shared
155 ) async throws -> UserInfo {
156 var request = URLRequest(
157 url: environment.baseURL.appendingPathComponent("v1/client/oauth/userinfo"))
158 request.httpMethod = "GET"
159 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
160 request.setValue("application/json", forHTTPHeaderField: "Accept")
161 if let tenantId {
162 request.setValue(tenantId, forHTTPHeaderField: "X-Smbcloud-Tenant-Id")
163 }
164 return try await HTTPTransport(session: session).requestJSON(request, as: UserInfo.self)
165 }
166
167 // MARK: - Helpers
168
169 private static func base64URLNoPad(_ data: Data) -> String {
170 data.base64EncodedString()
171 .replacingOccurrences(of: "+", with: "-")
172 .replacingOccurrences(of: "/", with: "_")
173 .replacingOccurrences(of: "=", with: "")
174 }
175
176 private static func formEncoded(_ items: [(String, String)]) -> Data? {
177 var components = URLComponents()
178 components.queryItems = items.map { URLQueryItem(name: $0.0, value: $0.1) }
179 return components.percentEncodedQuery?.data(using: .utf8)
180 }
181 }