Decouple cross-platform AuthCore from Apple UI layer

Split the package into two products so the auth engine no longer depends on UIKit/AppKit, AuthenticationServices, or the Keychain: - AuthCore (new): headless Authorization Code + PKCE engine that builds on Apple, Linux, Windows, and Android. Contains SmbCloudAuthClient, session and user-info models/clients, the OIDC client, and a pluggable SmbCloudCredentialsStore protocol with an in-memory implementation. Uses swift-crypto for SHA256 off Apple platforms and FoundationNetworking for URLSession on non-Apple toolchains. - SmbCloudAuth: now the Apple-only UI/platform layer. SmbCloudWebAuth wraps the AuthCore engine for ASWebAuthenticationSession login, and the Keychain-backed SmbCloudCredentialsManager conforms to SmbCloudCredentialsStore. Re-exports AuthCore for source compatibility. Existing import SmbCloudAuth call sites keep working. Adds a verify-linux make target to guard the cross-platform build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8a91eKgZbXoofPViSNpD7

Claude committed Jun 22, 2026 at 14:33 UTC 58798a54288f9bdbed85aca347f8cda05599b8c9
15 files changed +755 -281
Makefile
+12 -2
@@ -27,7 +27,7 @@ LOCAL_FFI_MARKER := $(LOCAL_DIR)/use-local-ffi
27 LIB_NAME := libsmbcloud_auth.a
28 SUPPORTED_PLATFORMS := ios macos tvos visionos
29
30 -.PHONY: help platform ios macos tvos visionos validate build-bindgen prepare generate-swift stage-framework activate-local-ffi deactivate-local-ffi clean verify verify-example verify-apple-destinations verify-apple-destination
30 +.PHONY: help platform ios macos tvos visionos validate build-bindgen prepare generate-swift stage-framework activate-local-ffi deactivate-local-ffi clean verify verify-example verify-linux verify-apple-destinations verify-apple-destination
31
32 help:
33 @printf '%s\n' \
@@ -40,6 +40,7 @@ help:
40 ' make visionos Build smbcloud_authFFI.xcframework for visionOS device + simulator' \
41 ' make verify Build + test the main Swift package and build the example app package' \
42 ' make verify-example Build the packaged macOS example app' \
43 + ' make verify-linux Build + test the package on a non-Apple toolchain (AuthCore)' \
44 ' make verify-apple-destinations Build the public package for iOS, tvOS, and visionOS generic destinations' \
45 ' make deactivate-local-ffi Hide the optional local SmbCloudAuthFFI product' \
46 '' \
@@ -48,7 +49,9 @@ help:
49 ' Sources/SmbCloudAuthFFI/smbcloud_auth.swift (regenerated from the local Rust build)' \
50 '' \
51 'Notes:' \
51 - ' The public SmbCloudAuth product is pure Swift and always available.' \
52 + ' AuthCore is the cross-platform (Apple/Linux/Windows/Android) headless core.' \
53 + ' SmbCloudAuth is the Apple UI layer (hosted login + Keychain) on top of AuthCore.' \
54 + ' Both pure-Swift products build without local Rust artifacts.' \
55 ' Local Rust/UniFFI builds enable the optional SmbCloudAuthFFI product.' \
56 '' \
57 'Optional overrides:' \
@@ -181,6 +184,13 @@ verify:
184 verify-example:
185 swift build --package-path Examples/HostedLoginExample
186
187 +# Cross-platform check: AuthCore (and the rest of the package) must build and
188 +# test on non-Apple toolchains such as Linux, Windows, and Android.
189 +verify-linux:
190 + swift build
191 + swift build --target AuthCore
192 + swift test
193 +
194 verify-apple-destinations:
195 $(MAKE) verify-apple-destination PLATFORM=ios
196 $(MAKE) verify-apple-destination PLATFORM=tvos
Package.swift
+35 -2
@@ -19,10 +19,28 @@ let shouldEnableLocalFfi =
19
20 let targets: [Target] = {
21 var targets: [Target] = [
22 + // Cross-platform core. Builds on Apple platforms, Linux, Windows, and
23 + // Android. No UIKit/AppKit, AuthenticationServices, or Keychain.
24 + .target(
25 + name: "AuthCore",
26 + dependencies: [
27 + // CryptoKit is used on Apple platforms; swift-crypto provides the
28 + // same SHA256 API everywhere else.
29 + .product(
30 + name: "Crypto",
31 + package: "swift-crypto",
32 + condition: .when(platforms: [.linux, .windows, .android])
33 + )
34 + ],
35 + path: "Sources/AuthCore"
36 + ),
37 + // Apple-platform UI layer: ASWebAuthenticationSession hosted login and
38 + // the Keychain-backed credentials manager. Re-exports AuthCore.
39 .target(
40 name: "SmbCloudAuth",
41 + dependencies: ["AuthCore"],
42 path: "Sources/SmbCloudAuth"
25 - )
43 + ),
44 ]
45
46 if shouldEnableLocalFfi {
@@ -42,6 +60,14 @@ let targets: [Target] = {
60 )
61 }
62
63 + targets.append(
64 + .testTarget(
65 + name: "AuthCoreTests",
66 + dependencies: ["AuthCore"],
67 + path: "Tests/AuthCoreTests"
68 + )
69 + )
70 +
71 targets.append(
72 .testTarget(
73 name: "SmbCloudAuthTests",
@@ -55,7 +81,8 @@ let targets: [Target] = {
81
82 let products: [Product] = {
83 var products: [Product] = [
58 - .library(name: "SmbCloudAuth", targets: ["SmbCloudAuth"])
84 + .library(name: "AuthCore", targets: ["AuthCore"]),
85 + .library(name: "SmbCloudAuth", targets: ["SmbCloudAuth"]),
86 ]
87
88 if shouldEnableLocalFfi {
@@ -73,5 +100,11 @@ let package = Package(
100 .iOS(.v16), .macOS(.v14), .tvOS(.v16), .visionOS(.v1),
101 ],
102 products: products,
103 + dependencies: [
104 + .package(
105 + url: "https://github.com/apple/swift-crypto.git",
106 + "3.0.0" ..< "5.0.0"
107 + )
108 + ],
109 targets: targets
110 )
README.md
+65 -9
@@ -18,20 +18,32 @@
18
19 ---
20
21 -`smbcloud-auth-swift` is the Swift package for smbCloud Auth on Apple platforms.
21 +`smbcloud-auth-swift` is the Swift package for smbCloud Auth.
22
23 -It gives you:
23 +It ships **two products**:
24
25 -- `SmbCloudWebAuth`
25 +| Product | Platforms | What it's for |
26 +|---------|-----------|---------------|
27 +| **`AuthCore`** | Apple · Linux · Windows · Android | Headless, UI-free auth engine |
28 +| **`SmbCloudAuth`** | Apple only | Hosted login UI + Keychain on top of `AuthCore` |
29 +
30 +`AuthCore` gives you:
31 +
32 +- `SmbCloudAuthClient` — headless Authorization Code + PKCE engine
33 - `SmbCloudSession`
27 -- `SmbCloudCredentialsManager`
28 -- `SmbCloudUserInfoClient`
34 +- `SmbCloudUserInfo` / `SmbCloudUserInfoClient`
35 +- `SmbCloudCredentialsStore` (protocol) + `SmbCloudInMemoryCredentialsStore`
36 +
37 +`SmbCloudAuth` adds the Apple UI/platform layer and re-exports `AuthCore`:
38 +
39 +- `SmbCloudWebAuth` — `ASWebAuthenticationSession` hosted login
40 +- `SmbCloudCredentialsManager` — Keychain-backed `SmbCloudCredentialsStore`
41
42 The package is built for public clients:
43
44 - Authorization Code + PKCE
33 -- `ASWebAuthenticationSession`
34 -- Keychain-backed local session storage
45 +- `ASWebAuthenticationSession` (Apple) or your own user agent (everywhere else)
46 +- pluggable session storage (Keychain on Apple, your choice elsewhere)
47 - no shipped client secret
48
49 ## Status
@@ -61,7 +73,18 @@ targets: [
73 ]
74 ```
75
64 -## Quick example
76 +For the cross-platform (Linux/Windows/Android) core, depend on `AuthCore`
77 +instead:
78 +
79 +```swift
80 +targets: [
81 + .target(name: "MyApp", dependencies: [
82 + .product(name: "AuthCore", package: "smbcloud-auth-swift")
83 + ])
84 +]
85 +```
86 +
87 +## Quick example (Apple)
88
89 ```swift
90 import SmbCloudAuth
@@ -87,8 +110,39 @@ let user = try await webAuth.userInfo(session: session)
110 print(user.email ?? "Signed in")
111 ```
112
113 +## Quick example (cross-platform / headless)
114 +
115 +On Linux, Windows, Android, servers, or any place without
116 +`ASWebAuthenticationSession`, drive the user agent yourself and feed the
117 +callback URL back into `AuthCore`:
118 +
119 +```swift
120 +import AuthCore
121 +
122 +let client = try SmbCloudAuthClient(
123 + domain: "api.smbcloud.xyz",
124 + clientId: "your-public-client-id",
125 + redirectURL: URL(string: "myapp://auth/callback")!
126 +)
127 +
128 +// 1. Build the PKCE request and open `request.authorizeURL` in a browser.
129 +let request = try client.authorizationRequest()
130 +
131 +// 2. After the redirect, hand the callback URL back to AuthCore.
132 +let session = try await client.exchangeCallback(callbackURL, authorizationRequest: request)
133 +
134 +// 3. Persist however you like (e.g. SmbCloudInMemoryCredentialsStore or your own store).
135 +let store = SmbCloudInMemoryCredentialsStore()
136 +try store.store(session)
137 +
138 +let user = try await client.userInfo(session: session)
139 +print(user.email ?? "Signed in")
140 +```
141 +
142 ## Platforms
143
144 +`SmbCloudAuth` (hosted-login UI + Keychain):
145 +
146 | Platform | Minimum |
147 |----------|---------|
148 | iOS | 16.0 |
@@ -96,7 +150,9 @@ print(user.email ?? "Signed in")
150 | tvOS | 16.0 |
151 | visionOS | 1.0 |
152
99 -Hosted web login currently runs on iOS, macOS, and visionOS. The session, storage, and user info helpers can still be used on other supported Apple platforms.
153 +Hosted web login (`SmbCloudWebAuth`) runs on iOS, macOS, and visionOS. The session, storage, and user info helpers can still be used on other supported Apple platforms.
154 +
155 +`AuthCore` (headless engine) additionally builds on **Linux, Windows, and Android**. It has no dependency on UIKit/AppKit, `AuthenticationServices`, or the Keychain, and uses [swift-crypto](https://github.com/apple/swift-crypto) for PKCE off Apple platforms. Bring your own `SmbCloudCredentialsStore` (or use `SmbCloudInMemoryCredentialsStore`) for persistence.
156
157 ## Security note
158
Sources/AuthCore/SmbCloudAuthClient.swift new
+168
@@ -0,0 +1,168 @@
1 +import Foundation
2 +
3 +#if canImport(FoundationNetworking)
4 + import FoundationNetworking
5 +#endif
6 +
7 +/// A prepared OAuth 2.0 Authorization Code + PKCE request.
8 +///
9 +/// Hold on to this value for the lifetime of a single login attempt. After the
10 +/// user agent redirects back to your `redirectURL`, pass the callback URL and
11 +/// this request to ``SmbCloudAuthClient/exchangeCallback(_:authorizationRequest:)``.
12 +public struct SmbCloudAuthorizationRequest: Equatable, Hashable, Sendable {
13 + /// The fully formed `oauth/authorize` URL to load in a browser/user agent.
14 + public let authorizeURL: URL
15 + /// The opaque `state` value used to protect against CSRF / callback mix-ups.
16 + public let state: String
17 + /// The PKCE code verifier that must be presented when exchanging the code.
18 + public let codeVerifier: String
19 +
20 + public init(authorizeURL: URL, state: String, codeVerifier: String) {
21 + self.authorizeURL = authorizeURL
22 + self.state = state
23 + self.codeVerifier = codeVerifier
24 + }
25 +}
26 +
27 +/// Headless, cross-platform smbCloud Auth engine.
28 +///
29 +/// `SmbCloudAuthClient` performs the platform-independent parts of the hosted
30 +/// login flow — building the PKCE authorization request, validating the
31 +/// callback, exchanging the authorization code for a session, and fetching
32 +/// user info. It does **not** present any UI and has no dependency on
33 +/// `AuthenticationServices`, the Keychain, or UIKit/AppKit, so it builds and
34 +/// runs on Apple platforms as well as Linux, Windows, and Android.
35 +///
36 +/// On Apple platforms, prefer `SmbCloudWebAuth` (from the `SmbCloudAuth`
37 +/// product) for an end-to-end `ASWebAuthenticationSession`-based login. On
38 +/// other platforms, drive the user agent yourself and feed the callback URL
39 +/// back into ``exchangeCallback(_:authorizationRequest:)``.
40 +public final class SmbCloudAuthClient: @unchecked Sendable {
41 + /// The default OpenID Connect scopes (`openid profile email`).
42 + public static let defaultScopes: [String] = SmbCloudOpenIDConnectClient.defaultScopes
43 +
44 + /// The public OAuth client identifier.
45 + public let clientId: String
46 + /// The redirect URL registered for this client.
47 + public let redirectURL: URL
48 +
49 + private let client: SmbCloudOpenIDConnectClient
50 + private let userInfoClient: SmbCloudUserInfoClient
51 +
52 + public init(
53 + environment: SmbCloudEnvironment = .production,
54 + clientId: String,
55 + redirectURL: URL
56 + ) {
57 + let baseURL = SmbCloudBaseURLFactory.makeURL(for: environment)
58 + self.clientId = clientId
59 + self.redirectURL = redirectURL
60 + self.client = SmbCloudOpenIDConnectClient(baseURL: baseURL)
61 + self.userInfoClient = SmbCloudUserInfoClient(baseURL: baseURL)
62 + }
63 +
64 + public init(baseURL: URL, clientId: String, redirectURL: URL) {
65 + self.clientId = clientId
66 + self.redirectURL = redirectURL
67 + self.client = SmbCloudOpenIDConnectClient(baseURL: baseURL)
68 + self.userInfoClient = SmbCloudUserInfoClient(baseURL: baseURL)
69 + }
70 +
71 + public convenience init(domain: String, clientId: String, redirectURL: URL) throws {
72 + let baseURL = try SmbCloudBaseURLFactory.makeURL(from: domain)
73 + self.init(baseURL: baseURL, clientId: clientId, redirectURL: redirectURL)
74 + }
75 +
76 + /// The callback scheme derived from ``redirectURL``, if any.
77 + ///
78 + /// Useful when configuring a platform web-authentication session.
79 + public var callbackScheme: String? {
80 + guard let scheme = redirectURL.scheme, scheme.isEmpty == false else {
81 + return nil
82 + }
83 +
84 + return scheme
85 + }
86 +
87 + /// Builds a fresh PKCE authorization request.
88 + public func authorizationRequest(
89 + scopes: [String] = SmbCloudAuthClient.defaultScopes,
90 + audience: String? = nil
91 + ) throws -> SmbCloudAuthorizationRequest {
92 + let request = try client.authorizationRequest(
93 + clientId: clientId,
94 + redirectURL: redirectURL,
95 + scopes: scopes,
96 + audience: audience
97 + )
98 +
99 + return SmbCloudAuthorizationRequest(
100 + authorizeURL: request.authorizeURL,
101 + state: request.state,
102 + codeVerifier: request.codeVerifier
103 + )
104 + }
105 +
106 + /// Validates a redirect callback URL and extracts the authorization code.
107 + public func authorizationCode(from callbackURL: URL, expectedState: String) throws -> String {
108 + try client.parseAuthorizationCode(from: callbackURL, expectedState: expectedState)
109 + }
110 +
111 + /// Exchanges an authorization code for a session using the PKCE verifier.
112 + public func exchangeCode(_ code: String, codeVerifier: String) async throws -> SmbCloudSession {
113 + try await client.exchangeCode(
114 + clientId: clientId,
115 + redirectURL: redirectURL,
116 + code: code,
117 + codeVerifier: codeVerifier
118 + )
119 + }
120 +
121 + /// Completes the flow: validates the callback URL against `authorizationRequest`
122 + /// and exchanges the code for a session.
123 + public func exchangeCallback(
124 + _ callbackURL: URL,
125 + authorizationRequest: SmbCloudAuthorizationRequest
126 + ) async throws -> SmbCloudSession {
127 + let code = try authorizationCode(
128 + from: callbackURL,
129 + expectedState: authorizationRequest.state
130 + )
131 +
132 + return try await exchangeCode(code, codeVerifier: authorizationRequest.codeVerifier)
133 + }
134 +
135 + /// Convenience overload that persists the resulting session to a store.
136 + @discardableResult
137 + public func exchangeCallback(
138 + _ callbackURL: URL,
139 + authorizationRequest: SmbCloudAuthorizationRequest,
140 + credentialsStore: SmbCloudCredentialsStore?
141 + ) async throws -> SmbCloudSession {
142 + let session = try await exchangeCallback(
143 + callbackURL,
144 + authorizationRequest: authorizationRequest
145 + )
146 +
147 + try credentialsStore?.store(session)
148 + return session
149 + }
150 +
151 + public func userInfo(accessToken: String, tenantId: String? = nil) async throws
152 + -> SmbCloudUserInfo
153 + {
154 + try await userInfoClient.userInfo(accessToken: accessToken, tenantId: tenantId)
155 + }
156 +
157 + public func userInfo(session: SmbCloudSession, tenantId: String? = nil) async throws
158 + -> SmbCloudUserInfo
159 + {
160 + try await userInfo(accessToken: session.accessToken, tenantId: tenantId)
161 + }
162 +
163 + /// Clears any locally stored session. Hosted-login logout currently only
164 + /// removes the local session; it does not revoke tokens server-side.
165 + public func logout(credentialsStore: SmbCloudCredentialsStore? = nil) throws {
166 + try credentialsStore?.clear()
167 + }
168 +}
Sources/AuthCore/SmbCloudClientError.swift renamed
Sources/AuthCore/SmbCloudCredentialsStore.swift new
+69
@@ -0,0 +1,69 @@
1 +import Foundation
2 +
3 +/// Abstraction over session persistence.
4 +///
5 +/// `AuthCore` is platform-agnostic, so it does not assume a Keychain (or any
6 +/// particular storage backend). Provide a conforming type to persist sessions
7 +/// however is appropriate for your platform:
8 +///
9 +/// - On Apple platforms, `SmbCloudCredentialsManager` (from the `SmbCloudAuth`
10 +/// product) stores sessions in the Keychain.
11 +/// - For tests, scripts, or servers, use ``SmbCloudInMemoryCredentialsStore``.
12 +/// - On Linux / Windows / Android, implement this protocol on top of your
13 +/// platform's secure storage (e.g. libsecret, DPAPI, Android Keystore).
14 +public protocol SmbCloudCredentialsStore: Sendable {
15 + /// Persists (or replaces) the stored session.
16 + func store(_ session: SmbCloudSession) throws
17 + /// Returns the stored session, if any.
18 + func current() throws -> SmbCloudSession?
19 + /// Removes any stored session.
20 + func clear() throws
21 +}
22 +
23 +extension SmbCloudCredentialsStore {
24 + /// Returns the stored session only if it is still valid within `leeway` seconds.
25 + public func currentValidSession(leeway: TimeInterval = 60) throws -> SmbCloudSession? {
26 + guard let session = try current() else {
27 + return nil
28 + }
29 +
30 + return session.isValid(leeway: leeway) ? session : nil
31 + }
32 +
33 + /// Whether a valid (non-expired) session is currently stored.
34 + public func hasValidSession(leeway: TimeInterval = 60) throws -> Bool {
35 + try currentValidSession(leeway: leeway) != nil
36 + }
37 +}
38 +
39 +/// A thread-safe, in-memory ``SmbCloudCredentialsStore``.
40 +///
41 +/// Sessions live only for the lifetime of the process. This is handy for unit
42 +/// tests, command-line tools, and server-side flows, and it works identically
43 +/// on every platform AuthCore supports.
44 +public final class SmbCloudInMemoryCredentialsStore: SmbCloudCredentialsStore, @unchecked Sendable {
45 + private let lock = NSLock()
46 + private var session: SmbCloudSession?
47 +
48 + public init(session: SmbCloudSession? = nil) {
49 + self.session = session?.storedRepresentation()
50 + }
51 +
52 + public func store(_ session: SmbCloudSession) throws {
53 + lock.lock()
54 + defer { lock.unlock() }
55 + self.session = session.storedRepresentation()
56 + }
57 +
58 + public func current() throws -> SmbCloudSession? {
59 + lock.lock()
60 + defer { lock.unlock() }
61 + return session
62 + }
63 +
64 + public func clear() throws {
65 + lock.lock()
66 + defer { lock.unlock() }
67 + session = nil
68 + }
69 +}
Sources/AuthCore/SmbCloudModels.swift renamed
Sources/AuthCore/SmbCloudOpenIDConnectClient.swift renamed
+12 -1
@@ -1,6 +1,17 @@
1 -import CryptoKit
1 import Foundation
2
3 +#if canImport(FoundationNetworking)
4 + // URLSession lives in FoundationNetworking on non-Apple Swift distributions (Linux, Windows, Android).
5 + import FoundationNetworking
6 +#endif
7 +
8 +#if canImport(CryptoKit)
9 + import CryptoKit
10 +#else
11 + // swift-crypto provides the same SHA256 API on Linux, Windows, and Android.
12 + import Crypto
13 +#endif
14 +
15 internal struct SmbCloudOpenIDConnectAuthorizationRequest {
16 let authorizeURL: URL
17 let state: String
Sources/AuthCore/SmbCloudSession.swift renamed
+1 -1
@@ -66,7 +66,7 @@ extension SmbCloudSession {
66 )
67 }
68
69 - func storedRepresentation() -> SmbCloudSession {
69 + public func storedRepresentation() -> SmbCloudSession {
70 SmbCloudSession(
71 accessToken: accessToken,
72 refreshToken: refreshToken?.nilIfEmpty,
Sources/AuthCore/SmbCloudUserInfoClient.swift renamed
Sources/SmbCloudAuth/AuthCoreExports.swift new
+6
@@ -0,0 +1,6 @@
1 +// `SmbCloudAuth` is the Apple-platform UI layer on top of the cross-platform
2 +// `AuthCore` engine. Re-export `AuthCore` so existing `import SmbCloudAuth`
3 +// call sites keep seeing `SmbCloudSession`, `SmbCloudUserInfo`,
4 +// `SmbCloudClientError`, `SmbCloudAuthClient`, `SmbCloudCredentialsStore`,
5 +// `SmbCloudUserInfoClient`, and friends without an extra import.
6 +@_exported import AuthCore
Sources/SmbCloudAuth/SmbCloudCredentialsManager.swift
+134 -130
@@ -1,156 +1,160 @@
1 -import Foundation
2 -import Security
3 -
4 -public enum SmbCloudCredentialsManagerError: Error, LocalizedError, Sendable {
5 - case encodingFailed(String)
6 - case decodingFailed(String)
7 - case keychainOperationFailed(status: OSStatus, message: String)
8 -
9 - public var errorDescription: String? {
10 - switch self {
11 - case .encodingFailed(let message):
12 - return message
13 - case .decodingFailed(let message):
14 - return message
15 - case .keychainOperationFailed(_, let message):
16 - return message
17 - }
18 - }
19 -}
20 -
21 -public struct SmbCloudCredentialsManager {
22 - public let service: String
23 - public let account: String
24 - public let accessGroup: String?
25 -
26 - public init(
27 - service: String? = nil,
28 - account: String = "current",
29 - accessGroup: String? = nil
30 - ) {
31 - self.service = service?.nilIfEmpty ?? Self.defaultServiceName
32 - self.account = account
33 - self.accessGroup = accessGroup?.nilIfEmpty
34 - }
35 -
36 - public func store(_ session: SmbCloudSession) throws {
37 - let encodedSession: Data
38 - do {
39 - encodedSession = try JSONEncoder().encode(session.storedRepresentation())
40 - } catch {
41 - throw SmbCloudCredentialsManagerError.encodingFailed(
42 - "Failed to encode smbCloud session: \(error.localizedDescription)"
43 - )
44 - }
45 -
46 - var addQuery = baseQuery()
47 - addQuery[kSecValueData as String] = encodedSession
48 - addSecureAccessibility(to: &addQuery)
49 -
50 - let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
51 - switch addStatus {
52 - case errSecSuccess:
53 - return
54 - case errSecDuplicateItem:
55 - let attributesToUpdate = [
56 - kSecValueData as String: encodedSession
57 - ]
58 - let updateStatus = SecItemUpdate(
59 - baseQuery() as CFDictionary, attributesToUpdate as CFDictionary)
60 - guard updateStatus == errSecSuccess else {
61 - throw keychainError(for: updateStatus)
1 +#if canImport(Security)
2 + import AuthCore
3 + import Foundation
4 + import Security
5 +
6 + public enum SmbCloudCredentialsManagerError: Error, LocalizedError, Sendable {
7 + case encodingFailed(String)
8 + case decodingFailed(String)
9 + case keychainOperationFailed(status: OSStatus, message: String)
10 +
11 + public var errorDescription: String? {
12 + switch self {
13 + case .encodingFailed(let message):
14 + return message
15 + case .decodingFailed(let message):
16 + return message
17 + case .keychainOperationFailed(_, let message):
18 + return message
19 }
63 - default:
64 - throw keychainError(for: addStatus)
20 }
21 }
22
68 - public func current() throws -> SmbCloudSession? {
69 - var query = baseQuery()
70 - query[kSecReturnData as String] = true
71 - query[kSecMatchLimit as String] = kSecMatchLimitOne
72 -
73 - var item: CFTypeRef?
74 - let status = SecItemCopyMatching(query as CFDictionary, &item)
75 - switch status {
76 - case errSecSuccess:
77 - guard let data = item as? Data else {
78 - throw SmbCloudCredentialsManagerError.decodingFailed(
79 - "The keychain item did not contain valid session data."
80 - )
81 - }
23 + /// Keychain-backed ``SmbCloudCredentialsStore`` for Apple platforms.
24 + ///
25 + /// This type is only available where the `Security` framework exists
26 + /// (iOS, macOS, tvOS, watchOS, visionOS). On other platforms, conform your
27 + /// own type to ``SmbCloudCredentialsStore`` or use
28 + /// ``SmbCloudInMemoryCredentialsStore`` from `AuthCore`.
29 + public struct SmbCloudCredentialsManager: SmbCloudCredentialsStore {
30 + public let service: String
31 + public let account: String
32 + public let accessGroup: String?
33 +
34 + public init(
35 + service: String? = nil,
36 + account: String = "current",
37 + accessGroup: String? = nil
38 + ) {
39 + self.service = service?.smbCloudNilIfBlank ?? Self.defaultServiceName
40 + self.account = account
41 + self.accessGroup = accessGroup?.smbCloudNilIfBlank
42 + }
43
44 + public func store(_ session: SmbCloudSession) throws {
45 + let encodedSession: Data
46 do {
84 - return try JSONDecoder().decode(SmbCloudSession.self, from: data)
85 - .storedRepresentation()
47 + encodedSession = try JSONEncoder().encode(session.storedRepresentation())
48 } catch {
87 - throw SmbCloudCredentialsManagerError.decodingFailed(
88 - "Failed to decode smbCloud session: \(error.localizedDescription)"
49 + throw SmbCloudCredentialsManagerError.encodingFailed(
50 + "Failed to encode smbCloud session: \(error.localizedDescription)"
51 )
52 }
91 - case errSecItemNotFound:
92 - return nil
93 - default:
94 - throw keychainError(for: status)
95 - }
96 - }
53
98 - public func currentValidSession(leeway: TimeInterval = 60) throws -> SmbCloudSession? {
99 - guard let session = try current() else {
100 - return nil
54 + var addQuery = baseQuery()
55 + addQuery[kSecValueData as String] = encodedSession
56 + addSecureAccessibility(to: &addQuery)
57 +
58 + let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
59 + switch addStatus {
60 + case errSecSuccess:
61 + return
62 + case errSecDuplicateItem:
63 + let attributesToUpdate = [
64 + kSecValueData as String: encodedSession
65 + ]
66 + let updateStatus = SecItemUpdate(
67 + baseQuery() as CFDictionary, attributesToUpdate as CFDictionary)
68 + guard updateStatus == errSecSuccess else {
69 + throw keychainError(for: updateStatus)
70 + }
71 + default:
72 + throw keychainError(for: addStatus)
73 + }
74 }
75
103 - return session.isValid(leeway: leeway) ? session : nil
104 - }
105 -
106 - public func hasValidSession(leeway: TimeInterval = 60) throws -> Bool {
107 - try currentValidSession(leeway: leeway) != nil
108 - }
76 + public func current() throws -> SmbCloudSession? {
77 + var query = baseQuery()
78 + query[kSecReturnData as String] = true
79 + query[kSecMatchLimit as String] = kSecMatchLimitOne
80 +
81 + var item: CFTypeRef?
82 + let status = SecItemCopyMatching(query as CFDictionary, &item)
83 + switch status {
84 + case errSecSuccess:
85 + guard let data = item as? Data else {
86 + throw SmbCloudCredentialsManagerError.decodingFailed(
87 + "The keychain item did not contain valid session data."
88 + )
89 + }
90 +
91 + do {
92 + return try JSONDecoder().decode(SmbCloudSession.self, from: data)
93 + .storedRepresentation()
94 + } catch {
95 + throw SmbCloudCredentialsManagerError.decodingFailed(
96 + "Failed to decode smbCloud session: \(error.localizedDescription)"
97 + )
98 + }
99 + case errSecItemNotFound:
100 + return nil
101 + default:
102 + throw keychainError(for: status)
103 + }
104 + }
105
110 - public func clear() throws {
111 - let status = SecItemDelete(baseQuery() as CFDictionary)
112 - switch status {
113 - case errSecSuccess, errSecItemNotFound:
114 - return
115 - default:
116 - throw keychainError(for: status)
106 + public func clear() throws {
107 + let status = SecItemDelete(baseQuery() as CFDictionary)
108 + switch status {
109 + case errSecSuccess, errSecItemNotFound:
110 + return
111 + default:
112 + throw keychainError(for: status)
113 + }
114 }
115 }
119 -}
116
121 -extension SmbCloudCredentialsManager {
122 - fileprivate static var defaultServiceName: String {
123 - if let bundleIdentifier = Bundle.main.bundleIdentifier?.nilIfEmpty {
124 - return "\(bundleIdentifier).smbcloud.auth"
117 + extension SmbCloudCredentialsManager {
118 + fileprivate static var defaultServiceName: String {
119 + if let bundleIdentifier = Bundle.main.bundleIdentifier?.smbCloudNilIfBlank {
120 + return "\(bundleIdentifier).smbcloud.auth"
121 + }
122 +
123 + return "xyz.smbcloud.auth"
124 }
125
127 - return "xyz.smbcloud.auth"
128 - }
126 + fileprivate func baseQuery() -> [String: Any] {
127 + var query: [String: Any] = [
128 + kSecClass as String: kSecClassGenericPassword,
129 + kSecAttrService as String: service,
130 + kSecAttrAccount as String: account,
131 + ]
132
130 - fileprivate func baseQuery() -> [String: Any] {
131 - var query: [String: Any] = [
132 - kSecClass as String: kSecClassGenericPassword,
133 - kSecAttrService as String: service,
134 - kSecAttrAccount as String: account,
135 - ]
133 + if let accessGroup {
134 + query[kSecAttrAccessGroup as String] = accessGroup
135 + }
136
137 - if let accessGroup {
138 - query[kSecAttrAccessGroup as String] = accessGroup
137 + return query
138 }
139
141 - return query
142 - }
140 + fileprivate func addSecureAccessibility(to query: inout [String: Any]) {
141 + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
142 + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
143 + #endif
144 + }
145
144 - fileprivate func addSecureAccessibility(to query: inout [String: Any]) {
145 - #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
146 - query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
147 - #endif
146 + fileprivate func keychainError(for status: OSStatus) -> SmbCloudCredentialsManagerError {
147 + let message =
148 + SecCopyErrorMessageString(status, nil) as String?
149 + ?? "Keychain operation failed with status \(status)."
150 + return .keychainOperationFailed(status: status, message: message)
151 + }
152 }
153
150 - fileprivate func keychainError(for status: OSStatus) -> SmbCloudCredentialsManagerError {
151 - let message =
152 - SecCopyErrorMessageString(status, nil) as String?
153 - ?? "Keychain operation failed with status \(status)."
154 - return .keychainOperationFailed(status: status, message: message)
154 + extension String {
155 + /// Module-local helper mirroring AuthCore's internal `nilIfEmpty`.
156 + fileprivate var smbCloudNilIfBlank: String? {
157 + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
158 + }
159 }
156 -}
160 +#endif
Sources/SmbCloudAuth/SmbCloudWebAuth.swift
+48 -42
@@ -1,3 +1,4 @@
1 +import AuthCore
2 import Foundation
3
4 #if canImport(AuthenticationServices) && (os(iOS) || os(macOS) || os(visionOS))
@@ -6,13 +7,19 @@ import Foundation
7 public typealias SmbCloudPresentationAnchor = ASPresentationAnchor
8 #endif
9
10 +/// Apple-platform hosted login built on `AuthCore`.
11 +///
12 +/// `SmbCloudWebAuth` wraps the cross-platform ``SmbCloudAuthClient`` engine and
13 +/// adds an `ASWebAuthenticationSession`-driven `login(...)` for iOS, macOS, and
14 +/// visionOS. The non-interactive helpers (`userInfo`, `logout`, `clearSession`)
15 +/// are available wherever the package builds.
16 +///
17 +/// For headless or non-Apple usage (Linux, Windows, Android, servers, tests),
18 +/// use ``SmbCloudAuthClient`` from the `AuthCore` product directly.
19 public final class SmbCloudWebAuth: @unchecked Sendable {
10 - public static let defaultScopes: [String] = ["openid", "profile", "email"]
20 + public static let defaultScopes: [String] = SmbCloudAuthClient.defaultScopes
21
12 - private let clientId: String
13 - private let redirectURL: URL
14 - private let client: SmbCloudOpenIDConnectClient
15 - private let userInfoClient: SmbCloudUserInfoClient
22 + private let authClient: SmbCloudAuthClient
23
24 #if canImport(AuthenticationServices) && (os(iOS) || os(macOS) || os(visionOS))
25 @MainActor
@@ -27,42 +34,56 @@ public final class SmbCloudWebAuth: @unchecked Sendable {
34 clientId: String,
35 redirectURL: URL
36 ) {
30 - let baseURL = SmbCloudBaseURLFactory.makeURL(for: environment)
31 - self.clientId = clientId
32 - self.redirectURL = redirectURL
33 - self.client = SmbCloudOpenIDConnectClient(baseURL: baseURL)
34 - self.userInfoClient = SmbCloudUserInfoClient(baseURL: baseURL)
37 + self.authClient = SmbCloudAuthClient(
38 + environment: environment,
39 + clientId: clientId,
40 + redirectURL: redirectURL
41 + )
42 }
43
44 public init(baseURL: URL, clientId: String, redirectURL: URL) {
38 - self.clientId = clientId
39 - self.redirectURL = redirectURL
40 - self.client = SmbCloudOpenIDConnectClient(baseURL: baseURL)
41 - self.userInfoClient = SmbCloudUserInfoClient(baseURL: baseURL)
45 + self.authClient = SmbCloudAuthClient(
46 + baseURL: baseURL,
47 + clientId: clientId,
48 + redirectURL: redirectURL
49 + )
50 }
51
52 public convenience init(domain: String, clientId: String, redirectURL: URL) throws {
45 - let baseURL = try SmbCloudBaseURLFactory.makeURL(from: domain)
46 - self.init(baseURL: baseURL, clientId: clientId, redirectURL: redirectURL)
53 + let authClient = try SmbCloudAuthClient(
54 + domain: domain,
55 + clientId: clientId,
56 + redirectURL: redirectURL
57 + )
58 + self.init(authClient: authClient)
59 + }
60 +
61 + private init(authClient: SmbCloudAuthClient) {
62 + self.authClient = authClient
63 + }
64 +
65 + /// The underlying headless engine, exposed for advanced/cross-cutting use.
66 + public var client: SmbCloudAuthClient {
67 + authClient
68 }
69
70 public func userInfo(accessToken: String, tenantId: String? = nil) async throws
71 -> SmbCloudUserInfo
72 {
52 - try await userInfoClient.userInfo(accessToken: accessToken, tenantId: tenantId)
73 + try await authClient.userInfo(accessToken: accessToken, tenantId: tenantId)
74 }
75
76 public func userInfo(session: SmbCloudSession, tenantId: String? = nil) async throws
77 -> SmbCloudUserInfo
78 {
58 - try await userInfo(accessToken: session.accessToken, tenantId: tenantId)
79 + try await authClient.userInfo(session: session, tenantId: tenantId)
80 }
81
61 - public func clearSession(credentialsManager: SmbCloudCredentialsManager? = nil) throws {
82 + public func clearSession(credentialsManager: SmbCloudCredentialsStore? = nil) throws {
83 try credentialsManager?.clear()
84 }
85
65 - public func logout(credentialsManager: SmbCloudCredentialsManager? = nil) throws {
86 + public func logout(credentialsManager: SmbCloudCredentialsStore? = nil) throws {
87 try clearSession(credentialsManager: credentialsManager)
88 }
89 }
@@ -75,21 +96,18 @@ public final class SmbCloudWebAuth: @unchecked Sendable {
96 scopes: [String] = SmbCloudWebAuth.defaultScopes,
97 audience: String? = nil,
98 prefersEphemeralSession: Bool = false,
78 - credentialsManager: SmbCloudCredentialsManager? = nil
99 + credentialsManager: SmbCloudCredentialsStore? = nil
100 ) async throws -> SmbCloudSession {
101 guard activeAuthenticationSession == nil else {
102 throw SmbCloudClientError.loginInProgress
103 }
104
84 - let authorizationRequest = try client.authorizationRequest(
85 - clientId: clientId,
86 - redirectURL: redirectURL,
105 + let authorizationRequest = try authClient.authorizationRequest(
106 scopes: scopes,
107 audience: audience
108 )
109
91 - let callbackScheme = redirectURL.scheme?.nilIfEmpty
92 - guard let callbackScheme else {
110 + guard let callbackScheme = authClient.callbackScheme else {
111 throw SmbCloudClientError.invalidRedirectURL(
112 "The redirect URL must include a callback scheme."
113 )
@@ -157,23 +175,11 @@ public final class SmbCloudWebAuth: @unchecked Sendable {
175 }
176 }
177
160 - let authorizationCode = try client.parseAuthorizationCode(
161 - from: callbackURL,
162 - expectedState: authorizationRequest.state
178 + return try await authClient.exchangeCallback(
179 + callbackURL,
180 + authorizationRequest: authorizationRequest,
181 + credentialsStore: credentialsManager
182 )
164 -
165 - let session = try await client.exchangeCode(
166 - clientId: clientId,
167 - redirectURL: redirectURL,
168 - code: authorizationCode,
169 - codeVerifier: authorizationRequest.codeVerifier
170 - )
171 -
172 - if let credentialsManager {
173 - try credentialsManager.store(session)
174 - }
175 -
176 - return session
183 }
184
185 private static func authenticationError(from error: Error) -> SmbCloudClientError {
Tests/AuthCoreTests/AuthCoreTests.swift new
+182
@@ -0,0 +1,182 @@
1 +import Foundation
2 +import XCTest
3 +
4 +@testable import AuthCore
5 +
6 +final class AuthCoreTests: XCTestCase {
7 + func testSessionScopeJoinsScopesWithSpaces() {
8 + let session = SmbCloudSession(
9 + accessToken: "token",
10 + scopes: ["openid", "profile", "email"]
11 + )
12 +
13 + XCTAssertEqual(session.scope, "openid profile email")
14 + XCTAssertEqual(session.authorizationHeaderValue, "Bearer token")
15 + }
16 +
17 + func testSessionValidityReflectsExpiryDate() {
18 + let futureSession = SmbCloudSession(
19 + accessToken: "future-token",
20 + expiresAt: Date().addingTimeInterval(300)
21 + )
22 + let pastSession = SmbCloudSession(
23 + accessToken: "past-token",
24 + expiresAt: Date().addingTimeInterval(-300)
25 + )
26 +
27 + XCTAssertTrue(futureSession.isValid())
28 + XCTAssertFalse(futureSession.isExpired)
29 + XCTAssertFalse(pastSession.isValid(leeway: 0))
30 + XCTAssertTrue(pastSession.isExpired)
31 + }
32 +
33 + func testSessionCodableRoundTripPreservesValues() throws {
34 + let originalSession = SmbCloudSession(
35 + accessToken: "access-token",
36 + refreshToken: "refresh-token",
37 + idToken: "id-token",
38 + tokenType: "Bearer",
39 + expiresAt: Date(timeIntervalSince1970: 1_750_000_000),
40 + scopes: ["openid", "profile", "email"]
41 + )
42 +
43 + let encodedSession = try JSONEncoder().encode(originalSession)
44 + let decodedSession = try JSONDecoder().decode(SmbCloudSession.self, from: encodedSession)
45 +
46 + XCTAssertEqual(decodedSession, originalSession)
47 + }
48 +
49 + func testInMemoryCredentialsStoreRoundTrips() throws {
50 + let store = SmbCloudInMemoryCredentialsStore()
51 + XCTAssertNil(try store.current())
52 +
53 + let session = SmbCloudSession(
54 + accessToken: "access-token",
55 + expiresAt: Date().addingTimeInterval(300),
56 + scopes: ["openid", "openid", " profile "]
57 + )
58 + try store.store(session)
59 +
60 + // storedRepresentation() normalizes/uniques scopes.
61 + XCTAssertEqual(try store.current()?.scopes, ["openid", "profile"])
62 + XCTAssertTrue(try store.hasValidSession())
63 + XCTAssertNotNil(try store.currentValidSession())
64 +
65 + try store.clear()
66 + XCTAssertNil(try store.current())
67 + XCTAssertFalse(try store.hasValidSession())
68 + }
69 +
70 + func testExpiredSessionIsNotReturnedAsValid() throws {
71 + let store = SmbCloudInMemoryCredentialsStore(
72 + session: SmbCloudSession(
73 + accessToken: "stale",
74 + expiresAt: Date().addingTimeInterval(-300)
75 + )
76 + )
77 +
78 + XCTAssertNotNil(try store.current())
79 + XCTAssertNil(try store.currentValidSession())
80 + XCTAssertFalse(try store.hasValidSession())
81 + }
82 +
83 + func testInvalidUserInfoDomainThrowsInvalidBaseUrlError() {
84 + XCTAssertThrowsError(try SmbCloudUserInfoClient(domain: " ")) { error in
85 + guard case SmbCloudClientError.invalidBaseURL = error else {
86 + return XCTFail("Expected invalidBaseURL error, got: \(error)")
87 + }
88 + }
89 + }
90 +
91 + func testInvalidAuthClientDomainThrowsInvalidBaseUrlError() {
92 + XCTAssertThrowsError(
93 + try SmbCloudAuthClient(
94 + domain: " ",
95 + clientId: "public-client-id",
96 + redirectURL: URL(string: "myapp://auth/callback")!
97 + )
98 + ) { error in
99 + guard case SmbCloudClientError.invalidBaseURL = error else {
100 + return XCTFail("Expected invalidBaseURL error, got: \(error)")
101 + }
102 + }
103 + }
104 +
105 + func testHttpLoopbackDomainIsAllowed() {
106 + XCTAssertNoThrow(
107 + try SmbCloudAuthClient(
108 + domain: "http://localhost:8088",
109 + clientId: "public-client-id",
110 + redirectURL: URL(string: "myapp://auth/callback")!
111 + )
112 + )
113 + XCTAssertNoThrow(try SmbCloudUserInfoClient(domain: "http://127.0.0.1:8088"))
114 + }
115 +
116 + func testHttpRemoteDomainThrowsInvalidBaseUrlError() {
117 + XCTAssertThrowsError(try SmbCloudUserInfoClient(domain: "http://api.smbcloud.xyz")) {
118 + error in
119 + guard case SmbCloudClientError.invalidBaseURL = error else {
120 + return XCTFail("Expected invalidBaseURL error, got: \(error)")
121 + }
122 + }
123 +
124 + XCTAssertThrowsError(
125 + try SmbCloudAuthClient(
126 + domain: "http://api.smbcloud.xyz",
127 + clientId: "public-client-id",
128 + redirectURL: URL(string: "myapp://auth/callback")!
129 + )
130 + ) { error in
131 + guard case SmbCloudClientError.invalidBaseURL = error else {
132 + return XCTFail("Expected invalidBaseURL error, got: \(error)")
133 + }
134 + }
135 + }
136 +
137 + func testAuthorizationRequestProducesPkceChallenge() throws {
138 + let client = SmbCloudAuthClient(
139 + baseURL: URL(string: "https://api.smbcloud.xyz")!,
140 + clientId: "public-client-id",
141 + redirectURL: URL(string: "myapp://auth/callback")!
142 + )
143 +
144 + let request = try client.authorizationRequest()
145 + let components = URLComponents(
146 + url: request.authorizeURL,
147 + resolvingAgainstBaseURL: false
148 + )
149 + let queryItems = components?.queryItems ?? []
150 +
151 + XCTAssertEqual(queryItems.firstValue(named: "response_type"), "code")
152 + XCTAssertEqual(queryItems.firstValue(named: "code_challenge_method"), "S256")
153 + XCTAssertEqual(queryItems.firstValue(named: "client_id"), "public-client-id")
154 + XCTAssertEqual(queryItems.firstValue(named: "state"), request.state)
155 + XCTAssertEqual(queryItems.firstValue(named: "scope"), "openid profile email")
156 + XCTAssertFalse(request.codeVerifier.isEmpty)
157 + XCTAssertNotNil(queryItems.firstValue(named: "code_challenge"))
158 + }
159 +
160 + func testCallbackScheme() {
161 + let client = SmbCloudAuthClient(
162 + baseURL: URL(string: "https://api.smbcloud.xyz")!,
163 + clientId: "public-client-id",
164 + redirectURL: URL(string: "myapp://auth/callback")!
165 + )
166 +
167 + XCTAssertEqual(client.callbackScheme, "myapp")
168 + }
169 +
170 + func testStateMismatchErrorDescriptionDoesNotExposeStateValues() {
171 + let error = SmbCloudClientError.stateMismatch(
172 + expected: "expected-state",
173 + received: "received-state"
174 + )
175 +
176 + XCTAssertEqual(error.errorDescription, "The callback state did not match.")
177 + }
178 +
179 + func testDefaultScopesMatchOidcDefaults() {
180 + XCTAssertEqual(SmbCloudAuthClient.defaultScopes, ["openid", "profile", "email"])
181 + }
182 +}
Tests/SmbCloudAuthTests/SmbCloudAuthTests.swift
+23 -94
@@ -1,64 +1,11 @@
1 import Foundation
2 import XCTest
3
4 -@testable import SmbCloudAuth
4 +import SmbCloudAuth
5
6 final class SmbCloudAuthTests: XCTestCase {
7 - func testSessionScopeJoinsScopesWithSpaces() {
8 - let session = SmbCloudSession(
9 - accessToken: "token",
10 - scopes: ["openid", "profile", "email"]
11 - )
12 -
13 - XCTAssertEqual(session.scope, "openid profile email")
14 - XCTAssertEqual(session.authorizationHeaderValue, "Bearer token")
15 - }
16 -
17 - func testSessionValidityReflectsExpiryDate() {
18 - let futureSession = SmbCloudSession(
19 - accessToken: "future-token",
20 - expiresAt: Date().addingTimeInterval(300)
21 - )
22 - let pastSession = SmbCloudSession(
23 - accessToken: "past-token",
24 - expiresAt: Date().addingTimeInterval(-300)
25 - )
26 -
27 - XCTAssertTrue(futureSession.isValid())
28 - XCTAssertFalse(futureSession.isExpired)
29 - XCTAssertFalse(pastSession.isValid(leeway: 0))
30 - XCTAssertTrue(pastSession.isExpired)
31 - }
32 -
33 - func testSessionCodableRoundTripPreservesValues() throws {
34 - let originalSession = SmbCloudSession(
35 - accessToken: "access-token",
36 - refreshToken: "refresh-token",
37 - idToken: "id-token",
38 - tokenType: "Bearer",
39 - expiresAt: Date(timeIntervalSince1970: 1_750_000_000),
40 - scopes: ["openid", "profile", "email"]
41 - )
42 -
43 - let encodedSession = try JSONEncoder().encode(originalSession)
44 - let decodedSession = try JSONDecoder().decode(SmbCloudSession.self, from: encodedSession)
45 -
46 - XCTAssertEqual(decodedSession, originalSession)
47 - }
48 -
49 - func testCredentialsManagerUsesExplicitServiceWhenProvided() {
50 - let manager = SmbCloudCredentialsManager(service: "xyz.smbcloud.tests", account: "tester")
51 -
52 - XCTAssertEqual(manager.service, "xyz.smbcloud.tests")
53 - XCTAssertEqual(manager.account, "tester")
54 - }
55 -
56 - func testInvalidUserInfoDomainThrowsInvalidBaseUrlError() {
57 - XCTAssertThrowsError(try SmbCloudUserInfoClient(domain: " ")) { error in
58 - guard case SmbCloudClientError.invalidBaseURL = error else {
59 - return XCTFail("Expected invalidBaseURL error, got: \(error)")
60 - }
61 - }
7 + func testWebAuthDefaultScopesMatchOidcDefaults() {
8 + XCTAssertEqual(SmbCloudWebAuth.defaultScopes, ["openid", "profile", "email"])
9 }
10
11 func testInvalidWebAuthDomainThrowsInvalidBaseUrlError() {
@@ -75,48 +22,30 @@ final class SmbCloudAuthTests: XCTestCase {
22 }
23 }
24
78 - func testHttpLoopbackDomainIsAllowed() {
79 - XCTAssertNoThrow(
80 - try SmbCloudWebAuth(
81 - domain: "http://localhost:8088",
82 - clientId: "public-client-id",
83 - redirectURL: URL(string: "myapp://auth/callback")!
84 - )
25 + func testWebAuthExposesUnderlyingAuthClient() throws {
26 + let webAuth = try SmbCloudWebAuth(
27 + domain: "api.smbcloud.xyz",
28 + clientId: "public-client-id",
29 + redirectURL: URL(string: "myapp://auth/callback")!
30 )
86 - XCTAssertNoThrow(try SmbCloudUserInfoClient(domain: "http://127.0.0.1:8088"))
87 - }
88 -
89 - func testHttpRemoteDomainThrowsInvalidBaseUrlError() {
90 - XCTAssertThrowsError(try SmbCloudUserInfoClient(domain: "http://api.smbcloud.xyz")) {
91 - error in
92 - guard case SmbCloudClientError.invalidBaseURL = error else {
93 - return XCTFail("Expected invalidBaseURL error, got: \(error)")
94 - }
95 - }
31
97 - XCTAssertThrowsError(
98 - try SmbCloudWebAuth(
99 - domain: "http://api.smbcloud.xyz",
100 - clientId: "public-client-id",
101 - redirectURL: URL(string: "myapp://auth/callback")!
102 - )
103 - ) { error in
104 - guard case SmbCloudClientError.invalidBaseURL = error else {
105 - return XCTFail("Expected invalidBaseURL error, got: \(error)")
106 - }
107 - }
32 + XCTAssertEqual(webAuth.client.clientId, "public-client-id")
33 + XCTAssertEqual(webAuth.client.callbackScheme, "myapp")
34 }
35
110 - func testStateMismatchErrorDescriptionDoesNotExposeStateValues() {
111 - let error = SmbCloudClientError.stateMismatch(
112 - expected: "expected-state",
113 - received: "received-state"
114 - )
36 + #if canImport(Security)
37 + func testCredentialsManagerUsesExplicitServiceWhenProvided() {
38 + let manager = SmbCloudCredentialsManager(
39 + service: "xyz.smbcloud.tests", account: "tester")
40
116 - XCTAssertEqual(error.errorDescription, "The callback state did not match.")
117 - }
41 + XCTAssertEqual(manager.service, "xyz.smbcloud.tests")
42 + XCTAssertEqual(manager.account, "tester")
43 + }
44
119 - func testDefaultScopesMatchOidcDefaults() {
120 - XCTAssertEqual(SmbCloudWebAuth.defaultScopes, ["openid", "profile", "email"])
121 - }
45 + func testCredentialsManagerConformsToCredentialsStore() {
46 + let manager: SmbCloudCredentialsStore = SmbCloudCredentialsManager(
47 + service: "xyz.smbcloud.tests")
48 + XCTAssertNotNil(manager)
49 + }
50 + #endif
51 }