Add Swift public-client SDK core and update framework naming
- Add initial Swift SDK sources for smbCloud Auth public-client flows: - SmbCloudWebAuth for hosted login via ASWebAuthenticationSession - SmbCloudSession for token/session management - SmbCloudCredentialsManager for secure session storage - SmbCloudUserInfoClient for user profile lookup - SmbCloudClientError for error handling - SmbCloudFallbackModels for fallback types - SmbCloudOpenIDConnectClient for OIDC helpers - Add SECURITY.md and ROADMAP.md with public-client guidance and roadmap - Update .gitignore, Makefile, and Package.swift to use smbcloud_authFFI.xcframework - Update README with revised product description
Seto Elkahfi committed
May 16, 2026 at 18:43 UTC
c011284a6154213c1dba043f3dc8c37443ae087c
13 files changed
+1524
-203
.gitignore
+1
-1
@@ -1,3 +1,3 @@
1
-SmbCloudAuthFramework.xcframework/
1
+smbcloud_authFFI.xcframework/
2
.local/
3
.build/
Makefile
+61
-18
@@ -17,27 +17,29 @@ VISIONOS_DEPLOYMENT_TARGET ?= 1.0
17
LOCAL_DIR := $(SWIFT_REPO)/.local
18
GENERATED_DIR := $(LOCAL_DIR)/generated/SmbCloudAuth
19
HEADERS_DIR := $(LOCAL_DIR)/Headers
20
-FRAMEWORK_DIR := $(SWIFT_REPO)/SmbCloudAuthFramework.xcframework
20
+FRAMEWORK_STAGING_DIR := $(LOCAL_DIR)/frameworks
21
+FRAMEWORK_DIR := $(SWIFT_REPO)/smbcloud_authFFI.xcframework
22
+SLICE_FRAMEWORK_NAME := smbcloud_authFFI.framework
23
+FRAMEWORK_BINARY_NAME := smbcloud_authFFI
24
SWIFT_GLUE := $(SWIFT_REPO)/Sources/SmbCloudAuth/smbcloud_auth.swift
25
26
LIB_NAME := libsmbcloud_auth.a
24
-
27
SUPPORTED_PLATFORMS := ios macos tvos visionos
28
27
-.PHONY: help platform ios macos tvos visionos validate build-bindgen prepare generate-swift clean
29
+.PHONY: help platform ios macos tvos visionos validate build-bindgen prepare generate-swift stage-framework clean
30
31
help:
32
@printf '%s\n' \
33
'SmbCloudAuth Swift — local Apple-platform builds' \
34
'' \
35
'Usage:' \
34
- ' make ios Build SmbCloudAuthFramework.xcframework for iOS device + simulator' \
35
- ' make macos Build SmbCloudAuthFramework.xcframework for macOS (Apple silicon)' \
36
- ' make tvos Build SmbCloudAuthFramework.xcframework for tvOS device + simulator' \
37
- ' make visionos Build SmbCloudAuthFramework.xcframework for visionOS device + simulator' \
36
+ ' make ios Build smbcloud_authFFI.xcframework for iOS device + simulator' \
37
+ ' make macos Build smbcloud_authFFI.xcframework for macOS (Apple silicon)' \
38
+ ' make tvos Build smbcloud_authFFI.xcframework for tvOS device + simulator' \
39
+ ' make visionos Build smbcloud_authFFI.xcframework for visionOS device + simulator' \
40
'' \
41
'Artifacts:' \
40
- ' SmbCloudAuthFramework.xcframework/' \
42
+ ' smbcloud_authFFI.xcframework/' \
43
' Sources/SmbCloudAuth/smbcloud_auth.swift (regenerated from the local Rust build)' \
44
'' \
45
'Optional overrides:' \
@@ -59,32 +61,69 @@ build-bindgen: validate
61
cargo $(RUST_STABLE) build --manifest-path "$(APPLE_CRATE_MANIFEST)" --features bindgen --bin uniffi-bindgen --release
62
63
prepare:
62
- @rm -rf "$(FRAMEWORK_DIR)" "$(GENERATED_DIR)" "$(HEADERS_DIR)"
63
- @mkdir -p "$(GENERATED_DIR)" "$(HEADERS_DIR)"
64
+ @rm -rf "$(FRAMEWORK_DIR)" "$(GENERATED_DIR)" "$(HEADERS_DIR)" "$(FRAMEWORK_STAGING_DIR)"
65
+ @mkdir -p "$(GENERATED_DIR)" "$(HEADERS_DIR)" "$(FRAMEWORK_STAGING_DIR)"
66
67
generate-swift:
68
@test -n "$(BINDGEN_INPUT)" || { echo "BINDGEN_INPUT is required"; exit 1; }
69
cd "$(CLI_REPO)" && "$(BINDGEN)" generate --library "$(BINDGEN_INPUT)" --language swift --out-dir "$(GENERATED_DIR)"
70
cp "$(GENERATED_DIR)/smbcloud_auth.swift" "$(SWIFT_GLUE)"
71
cp "$(GENERATED_DIR)/smbcloud_authFFI.h" "$(HEADERS_DIR)/smbcloud_authFFI.h"
70
- cp "$(GENERATED_DIR)/smbcloud_authFFI.modulemap" "$(HEADERS_DIR)/module.modulemap"
72
@echo "Updated $(SWIFT_GLUE)"
73
74
+stage-framework:
75
+ @test -n "$(LIB_INPUT)" || { echo "LIB_INPUT is required"; exit 1; }
76
+ @test -n "$(FRAMEWORK_OUTPUT)" || { echo "FRAMEWORK_OUTPUT is required"; exit 1; }
77
+ @rm -rf "$(FRAMEWORK_OUTPUT)"
78
+ @mkdir -p "$(FRAMEWORK_OUTPUT)/Headers" "$(FRAMEWORK_OUTPUT)/Modules"
79
+ cp "$(LIB_INPUT)" "$(FRAMEWORK_OUTPUT)/$(FRAMEWORK_BINARY_NAME)"
80
+ cp "$(HEADERS_DIR)/smbcloud_authFFI.h" "$(FRAMEWORK_OUTPUT)/Headers/smbcloud_authFFI.h"
81
+ @printf '%s\n' \
82
+ 'framework module smbcloud_authFFI {' \
83
+ ' header "smbcloud_authFFI.h"' \
84
+ ' export *' \
85
+ ' use "Darwin"' \
86
+ ' use "_Builtin_stdbool"' \
87
+ ' use "_Builtin_stdint"' \
88
+ '}' > "$(FRAMEWORK_OUTPUT)/Modules/module.modulemap"
89
+ @printf '%s\n' \
90
+ '<?xml version="1.0" encoding="UTF-8"?>' \
91
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
92
+ '<plist version="1.0">' \
93
+ '<dict>' \
94
+ ' <key>CFBundleIdentifier</key>' \
95
+ ' <string>xyz.smbcloud.smbcloud-auth-ffi</string>' \
96
+ ' <key>CFBundleName</key>' \
97
+ ' <string>$(FRAMEWORK_BINARY_NAME)</string>' \
98
+ ' <key>CFBundleExecutable</key>' \
99
+ ' <string>$(FRAMEWORK_BINARY_NAME)</string>' \
100
+ ' <key>CFBundlePackageType</key>' \
101
+ ' <string>FMWK</string>' \
102
+ ' <key>CFBundleShortVersionString</key>' \
103
+ ' <string>0.4.1</string>' \
104
+ ' <key>CFBundleVersion</key>' \
105
+ ' <string>1</string>' \
106
+ '</dict>' \
107
+ '</plist>' > "$(FRAMEWORK_OUTPUT)/Info.plist"
108
+
109
ios: build-bindgen prepare
110
IPHONEOS_DEPLOYMENT_TARGET=$(IOS_DEPLOYMENT_TARGET) cargo $(RUST_STABLE) rustc --manifest-path "$(CARGO_MANIFEST)" --package smbcloud-auth-sdk-apple --target aarch64-apple-ios --release --lib --crate-type staticlib
111
IPHONEOS_DEPLOYMENT_TARGET=$(IOS_DEPLOYMENT_TARGET) cargo $(RUST_STABLE) rustc --manifest-path "$(CARGO_MANIFEST)" --package smbcloud-auth-sdk-apple --target aarch64-apple-ios-sim --release --lib --crate-type staticlib
112
$(MAKE) generate-swift BINDGEN_INPUT="$(CLI_REPO)/target/aarch64-apple-ios/release/$(LIB_NAME)"
113
+ $(MAKE) stage-framework LIB_INPUT="$(CLI_REPO)/target/aarch64-apple-ios/release/$(LIB_NAME)" FRAMEWORK_OUTPUT="$(FRAMEWORK_STAGING_DIR)/ios/$(SLICE_FRAMEWORK_NAME)"
114
+ $(MAKE) stage-framework LIB_INPUT="$(CLI_REPO)/target/aarch64-apple-ios-sim/release/$(LIB_NAME)" FRAMEWORK_OUTPUT="$(FRAMEWORK_STAGING_DIR)/ios-sim/$(SLICE_FRAMEWORK_NAME)"
115
xcodebuild -create-xcframework \
78
- -library "$(CLI_REPO)/target/aarch64-apple-ios/release/$(LIB_NAME)" -headers "$(HEADERS_DIR)" \
79
- -library "$(CLI_REPO)/target/aarch64-apple-ios-sim/release/$(LIB_NAME)" -headers "$(HEADERS_DIR)" \
116
+ -framework "$(FRAMEWORK_STAGING_DIR)/ios/$(SLICE_FRAMEWORK_NAME)" \
117
+ -framework "$(FRAMEWORK_STAGING_DIR)/ios-sim/$(SLICE_FRAMEWORK_NAME)" \
118
-output "$(FRAMEWORK_DIR)"
119
@echo "Built $(FRAMEWORK_DIR) for iOS"
120
121
macos: build-bindgen prepare
122
MACOSX_DEPLOYMENT_TARGET=$(MACOS_DEPLOYMENT_TARGET) cargo $(RUST_STABLE) rustc --manifest-path "$(CARGO_MANIFEST)" --package smbcloud-auth-sdk-apple --target aarch64-apple-darwin --release --lib --crate-type staticlib
123
$(MAKE) generate-swift BINDGEN_INPUT="$(CLI_REPO)/target/aarch64-apple-darwin/release/$(LIB_NAME)"
124
+ $(MAKE) stage-framework LIB_INPUT="$(CLI_REPO)/target/aarch64-apple-darwin/release/$(LIB_NAME)" FRAMEWORK_OUTPUT="$(FRAMEWORK_STAGING_DIR)/macos/$(SLICE_FRAMEWORK_NAME)"
125
xcodebuild -create-xcframework \
87
- -library "$(CLI_REPO)/target/aarch64-apple-darwin/release/$(LIB_NAME)" -headers "$(HEADERS_DIR)" \
126
+ -framework "$(FRAMEWORK_STAGING_DIR)/macos/$(SLICE_FRAMEWORK_NAME)" \
127
-output "$(FRAMEWORK_DIR)"
128
@echo "Built $(FRAMEWORK_DIR) for macOS"
129
@@ -92,9 +131,11 @@ tvos: build-bindgen prepare
131
TVOS_DEPLOYMENT_TARGET=$(TVOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --package smbcloud-auth-sdk-apple --target aarch64-apple-tvos --release --lib --crate-type staticlib
132
TVOS_DEPLOYMENT_TARGET=$(TVOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --package smbcloud-auth-sdk-apple --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib
133
$(MAKE) generate-swift BINDGEN_INPUT="$(CLI_REPO)/target/aarch64-apple-tvos/release/$(LIB_NAME)"
134
+ $(MAKE) stage-framework LIB_INPUT="$(CLI_REPO)/target/aarch64-apple-tvos/release/$(LIB_NAME)" FRAMEWORK_OUTPUT="$(FRAMEWORK_STAGING_DIR)/tvos/$(SLICE_FRAMEWORK_NAME)"
135
+ $(MAKE) stage-framework LIB_INPUT="$(CLI_REPO)/target/aarch64-apple-tvos-sim/release/$(LIB_NAME)" FRAMEWORK_OUTPUT="$(FRAMEWORK_STAGING_DIR)/tvos-sim/$(SLICE_FRAMEWORK_NAME)"
136
xcodebuild -create-xcframework \
96
- -library "$(CLI_REPO)/target/aarch64-apple-tvos/release/$(LIB_NAME)" -headers "$(HEADERS_DIR)" \
97
- -library "$(CLI_REPO)/target/aarch64-apple-tvos-sim/release/$(LIB_NAME)" -headers "$(HEADERS_DIR)" \
137
+ -framework "$(FRAMEWORK_STAGING_DIR)/tvos/$(SLICE_FRAMEWORK_NAME)" \
138
+ -framework "$(FRAMEWORK_STAGING_DIR)/tvos-sim/$(SLICE_FRAMEWORK_NAME)" \
139
-output "$(FRAMEWORK_DIR)"
140
@echo "Built $(FRAMEWORK_DIR) for tvOS"
141
@@ -102,9 +143,11 @@ visionos: build-bindgen prepare
143
XROS_DEPLOYMENT_TARGET=$(VISIONOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --package smbcloud-auth-sdk-apple --target aarch64-apple-visionos --release --lib --crate-type staticlib
144
XROS_DEPLOYMENT_TARGET=$(VISIONOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --package smbcloud-auth-sdk-apple --target aarch64-apple-visionos-sim --release --lib --crate-type staticlib
145
$(MAKE) generate-swift BINDGEN_INPUT="$(CLI_REPO)/target/aarch64-apple-visionos/release/$(LIB_NAME)"
146
+ $(MAKE) stage-framework LIB_INPUT="$(CLI_REPO)/target/aarch64-apple-visionos/release/$(LIB_NAME)" FRAMEWORK_OUTPUT="$(FRAMEWORK_STAGING_DIR)/visionos/$(SLICE_FRAMEWORK_NAME)"
147
+ $(MAKE) stage-framework LIB_INPUT="$(CLI_REPO)/target/aarch64-apple-visionos-sim/release/$(LIB_NAME)" FRAMEWORK_OUTPUT="$(FRAMEWORK_STAGING_DIR)/visionos-sim/$(SLICE_FRAMEWORK_NAME)"
148
xcodebuild -create-xcframework \
106
- -library "$(CLI_REPO)/target/aarch64-apple-visionos/release/$(LIB_NAME)" -headers "$(HEADERS_DIR)" \
107
- -library "$(CLI_REPO)/target/aarch64-apple-visionos-sim/release/$(LIB_NAME)" -headers "$(HEADERS_DIR)" \
149
+ -framework "$(FRAMEWORK_STAGING_DIR)/visionos/$(SLICE_FRAMEWORK_NAME)" \
150
+ -framework "$(FRAMEWORK_STAGING_DIR)/visionos-sim/$(SLICE_FRAMEWORK_NAME)" \
151
-output "$(FRAMEWORK_DIR)"
152
@echo "Built $(FRAMEWORK_DIR) for visionOS"
153
Package.swift
+36
-37
@@ -2,40 +2,46 @@
2
import Foundation
3
import PackageDescription
4
5
-// ─────────────────────────────────────────────────────────────────────────────
6
-// SmbCloudAuth Swift SDK
7
-//
8
-// Local development: run `make ios` (or another platform target) to build
9
-// SmbCloudAuthFramework.xcframework from the Rust source. When the local
10
-// framework directory exists, SwiftPM links it directly.
11
-//
12
-// Distribution: consumers add this package via its Git URL. Without the local
13
-// framework on disk, SwiftPM downloads the pre-built zip from GitHub Releases.
14
-// ─────────────────────────────────────────────────────────────────────────────
15
-
5
let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
17
-let localFrameworkPath = "SmbCloudAuthFramework.xcframework"
6
+let localFrameworkPath = "smbcloud_authFFI.xcframework"
7
let localFrameworkAbsolutePath = packageRoot.appendingPathComponent(localFrameworkPath).path
8
+let fileManager = FileManager.default
9
+let localFrameworkEntries =
10
+ (try? fileManager.contentsOfDirectory(atPath: localFrameworkAbsolutePath)) ?? []
11
+let hasUsableLocalFramework =
12
+ fileManager.fileExists(atPath: localFrameworkAbsolutePath)
13
+ && localFrameworkEntries.contains(where: { $0.hasPrefix("macos") })
14
20
-// Update these two values when cutting a new release.
21
-let releaseFrameworkURL =
22
- "https://github.com/smbcloudXYZ/smbcloud-auth-swift/releases/download/0.4.1/SmbCloudAuthFramework.xcframework.zip"
23
-let releaseFrameworkChecksum =
24
- "0000000000000000000000000000000000000000000000000000000000000000"
15
+let ffiDependencies: [Target.Dependency] =
16
+ hasUsableLocalFramework ? [.target(name: "smbcloud_authFFI")] : []
17
+let excludedSourceFiles = hasUsableLocalFramework ? [] : ["smbcloud_auth.swift"]
18
+let swiftSettings: [SwiftSetting] =
19
+ hasUsableLocalFramework ? [.define("SMBCLOUD_AUTH_FFI_AVAILABLE")] : []
20
26
-let smbCloudAuthFrameworkTarget: Target
27
-if FileManager.default.fileExists(atPath: localFrameworkAbsolutePath) {
28
- smbCloudAuthFrameworkTarget = .binaryTarget(
29
- name: "SmbCloudAuthFramework",
30
- path: localFrameworkPath
31
- )
32
-} else {
33
- smbCloudAuthFrameworkTarget = .binaryTarget(
34
- name: "SmbCloudAuthFramework",
35
- url: releaseFrameworkURL,
36
- checksum: releaseFrameworkChecksum
21
+let targets: [Target] = {
22
+ var targets: [Target] = []
23
+
24
+ if hasUsableLocalFramework {
25
+ targets.append(
26
+ .binaryTarget(
27
+ name: "smbcloud_authFFI",
28
+ path: localFrameworkPath
29
+ )
30
+ )
31
+ }
32
+
33
+ targets.append(
34
+ .target(
35
+ name: "SmbCloudAuth",
36
+ dependencies: ffiDependencies,
37
+ path: "Sources/SmbCloudAuth",
38
+ exclude: excludedSourceFiles,
39
+ swiftSettings: swiftSettings
40
+ )
41
)
38
-}
42
+
43
+ return targets
44
+}()
45
46
let package = Package(
47
name: "SmbCloudAuth",
@@ -45,12 +51,5 @@ let package = Package(
51
products: [
52
.library(name: "SmbCloudAuth", targets: ["SmbCloudAuth"])
53
],
48
- targets: [
49
- smbCloudAuthFrameworkTarget,
50
- .target(
51
- name: "SmbCloudAuth",
52
- dependencies: [.target(name: "SmbCloudAuthFramework")],
53
- path: "Sources/SmbCloudAuth"
54
- ),
55
- ]
54
+ targets: targets
55
)
README.md
+178
-147
@@ -1,7 +1,7 @@
1
<h1 align="center">smbCloud Auth for Swift</h1>
2
3
<p align="center">
4
- <strong>Authenticate users on Apple platforms with <a href="https://smbcloud.xyz">smbCloud</a>.</strong>
4
+ <strong>Hosted authentication for Apple apps, built around secure public-client patterns.</strong>
5
</p>
6
7
<p align="center">
@@ -10,7 +10,7 @@
10
</p>
11
12
<p align="center">
13
- <a href="https://github.com/smbcloudXYZ/smbcloud-cli">CLI</a> ·
13
+ <a href="https://github.com/smbcloudXYZ/smbcloud-cli">Rust core</a> ·
14
<a href="https://www.npmjs.com/package/@smbcloud/sdk-auth">npm</a> ·
15
<a href="https://pypi.org/project/smbcloud-sdk-auth/">PyPI</a> ·
16
<a href="https://rubygems.org/gems/smbcloud-auth">RubyGems</a> ·
@@ -19,196 +19,227 @@
19
20
---
21
22
-## Platforms
22
+## Positioning
23
24
-| Platform | Minimum |
25
-|----------|---------|
26
-| iOS | 16.0 |
27
-| macOS | 14.0 |
28
-| tvOS | 16.0 |
29
-| visionOS | 1.0 |
24
+`smbcloud-auth-swift` is intended to become the **Apple-platform client SDK** for smbCloud Auth:
25
31
-## Installation
26
+- iOS, macOS, tvOS, and visionOS support
27
+- hosted auth via system browser sessions
28
+- OIDC Authorization Code + PKCE
29
+- token exchange and session/profile helpers
30
+- **no client secret embedded in the shipped app**
31
33
-### Xcode
32
+## Current status
33
35
-**File → Add Package Dependencies**, then enter:
34
+This package is still a **developer preview**, but the v1 public-client MVP surface now exists.
35
37
-```
38
-https://github.com/smbcloudXYZ/smbcloud-auth-swift
39
-```
36
+Today it provides:
37
41
-### Swift Package Manager
38
+- `SmbCloudWebAuth`
39
+- `SmbCloudSession`
40
+- `SmbCloudCredentialsManager`
41
+- `SmbCloudUserInfoClient`
42
+- hosted OIDC login with PKCE
43
+- `ASWebAuthenticationSession` orchestration
44
+- callback parsing + code exchange
45
+- local Keychain-backed session persistence
46
+- userinfo lookup
47
+- local session clearing/logout helper
48
43
-```swift
44
-dependencies: [
45
- .package(url: "https://github.com/smbcloudXYZ/smbcloud-auth-swift", from: "0.4.1")
46
-],
47
-targets: [
48
- .target(name: "MyApp", dependencies: [
49
- .product(name: "SmbCloudAuth", package: "smbcloud-auth-swift")
50
- ])
51
-]
52
-```
49
+The low-level generated UniFFI layer is still part of the repo for compatible XCFramework-based builds, but the package now has a first-class public Swift API for the main Apple login flow.
50
54
-### XcodeGen
55
-
56
-```yaml
57
-packages:
58
- SmbCloudAuth:
59
- url: https://github.com/smbcloudXYZ/smbcloud-auth-swift
60
- from: 0.4.1
61
-targets:
62
- MyApp:
63
- dependencies:
64
- - package: SmbCloudAuth
65
- product: SmbCloudAuth
66
-```
51
+## Security model
52
68
-## Quick Start
53
+### Recommended for public Apple apps
54
70
-```swift
71
-import SmbCloudAuth
55
+Use:
56
73
-let auth = SmbCloudAuth(
74
- environment: .production,
75
- appId: "your-app-id",
76
- appSecret: "your-app-secret"
77
-)
57
+- hosted auth
58
+- system browser sessions (`ASWebAuthenticationSession`)
59
+- OIDC Authorization Code + PKCE
60
79
-// Sign up
80
-let result = try await auth.signup(email: "user@example.com", password: "s3cret")
81
-
82
-// Log in
83
-let status = try await auth.login(email: "user@example.com", password: "s3cret")
84
-switch status {
85
-case .ready(let accessToken):
86
- print("Authenticated: \(accessToken)")
87
-case .incomplete(let errorCode):
88
- print("Account incomplete: \(errorCode)")
89
-case .notFound:
90
- print("Account not found")
91
-}
92
-
93
-// Get current user
94
-let user = try await auth.me(accessToken: "Bearer ...")
95
-print(user.email)
96
-
97
-// Log out
98
-try await auth.logout(accessToken: "Bearer ...")
99
-```
61
+This is the correct model for:
62
101
-## Apple Sign-In
63
+- App Store apps
64
+- open-source apps
65
+- consumer apps
66
+- partner apps you do not fully control
67
103
-```swift
104
-// Build the authorization URL
105
-let request = try auth.buildAppleAuthorizationRequest(
106
- redirectUri: "myapp://auth/callback",
107
- state: nil // auto-generated if nil
108
-)
68
+### Not recommended for shipped public apps
69
110
-// Open request.authorizeUrl in a browser or ASWebAuthenticationSession,
111
-// then parse the callback:
112
-let session = try auth.parseAppleCallbackUrl(
113
- callbackUrl: callbackUrl,
114
- expectedState: request.state
115
-)
116
-print(session.accessToken)
117
-```
70
+Do **not** embed an smbCloud Auth `app_secret` in:
71
119
-## OIDC (Authorization Code + PKCE)
72
+- the app bundle
73
+- `Info.plist`
74
+- local config copied into the app
75
+- the binary itself
76
121
-```swift
122
-// 1. Build authorization request
123
-let request = try auth.buildOidcAuthorizationRequest(
124
- oidcClientId: "your-oidc-client-id",
125
- redirectUri: "myapp://auth/callback"
126
-)
127
-// Open request.authorizeUrl in a browser...
77
+Even if it is not committed to Git, a secret inside a distributed client app is recoverable.
78
+
79
+### If you want native email/password forms
80
+
81
+Use a backend or BFF/proxy that holds the smbCloud Auth secret on the server.
82
+
83
+That pattern is valid when product UX requires native forms, but the server must own the confidential credentials.
84
+
85
+## Why this package exists
86
+
87
+Apple developers want a native SDK, not just protocol details.
88
+
89
+The goal is simple:
90
+
91
+1. start auth
92
+2. open hosted login in a system browser session
93
+3. handle callback
94
+4. exchange code
95
+5. store session
96
+6. load profile or log out
97
+
98
+## Secure integration patterns
99
+
100
+### 1. Hosted auth + PKCE — recommended
101
+
102
+Best for:
103
+
104
+- public apps
105
+- open-source apps
106
+- App Store distribution
107
+
108
+Flow:
109
129
-// 2. Parse the callback
130
-let callback = try auth.parseOidcCallbackUrl(callbackUrl: callbackUrl)
110
+1. build OIDC authorization request
111
+2. open hosted auth in `ASWebAuthenticationSession`
112
+3. receive callback URL
113
+4. parse `code` + `state`
114
+5. exchange code with PKCE verifier
115
+6. fetch user info
116
+7. store the resulting session locally
117
132
-// 3. Exchange the code for tokens
133
-let tokens = try await auth.exchangeOidcCode(
134
- oidcClientId: "your-oidc-client-id",
135
- redirectUri: "myapp://auth/callback",
136
- code: callback.code,
137
- codeVerifier: request.codeVerifier
118
+### 2. Backend/BFF proxy — native forms
119
+
120
+Best for:
121
+
122
+- teams that want a fully native email/password screen
123
+- apps with an existing backend
124
+- managed enterprise deployments
125
+
126
+Flow:
127
+
128
+1. app shows native login/signup UI
129
+2. app sends credentials to your backend
130
+3. backend talks to smbCloud Auth with confidential credentials
131
+4. backend returns an app-safe session/token response
132
+
133
+## Current capabilities
134
+
135
+The main public-client API now centers on:
136
+
137
+- `SmbCloudWebAuth.login(...)`
138
+- `SmbCloudWebAuth.userInfo(...)`
139
+- `SmbCloudCredentialsManager.store(...)`
140
+- `SmbCloudCredentialsManager.current()`
141
+- `SmbCloudCredentialsManager.currentValidSession(...)`
142
+- `SmbCloudSession`
143
+- `SmbCloudUserInfoClient.userInfo(...)`
144
+
145
+That gives Apple apps an end-to-end hosted auth flow without writing their own PKCE, callback, token exchange, and Keychain glue.
146
+
147
+## Product direction
148
+
149
+The package now exposes an API in this shape:
150
+
151
+```/dev/null/swift-example.swift#L1-L23
152
+import SmbCloudAuth
153
+
154
+let webAuth = try SmbCloudWebAuth(
155
+ domain: "api.smbcloud.xyz",
156
+ clientId: "your-public-client-id",
157
+ redirectURL: URL(string: "myapp://auth/callback")!
158
)
139
-print(tokens.accessToken)
159
141
-// 4. Fetch user info
142
-let userInfo = try await auth.getOidcUserinfo(
143
- accessToken: tokens.accessToken,
144
- tenantId: nil
160
+let credentials = SmbCloudCredentialsManager()
161
+let session = try await webAuth.login(
162
+ presentationAnchorProvider: {
163
+ window
164
+ },
165
+ credentialsManager: credentials
166
)
146
-print(userInfo.email ?? "no email")
147
-```
167
149
-## Error Handling
168
+let restored = try credentials.currentValidSession()
169
+let user = try await webAuth.userInfo(session: session)
170
+print(user.email ?? "")
171
+```
172
151
-All methods throw `SmbCloudAuthError` on failure:
173
+And you can fetch profile data without the full web auth wrapper when needed:
174
153
-```swift
154
-do {
155
- let status = try await auth.login(email: email, password: password)
156
-} catch let error as SmbCloudAuthError {
157
- switch error {
158
- case .api(let errorCode, let message):
159
- print("API error \(errorCode): \(message)")
160
- }
161
-}
175
+```/dev/null/swift-example.swift#L1-L12
176
+let userInfoClient = SmbCloudUserInfoClient(environment: .production)
177
+let user = try await userInfoClient.userInfo(accessToken: session.accessToken)
178
+print(user.sub)
179
```
180
164
-## API Reference
181
+## Installation
182
166
-### `SmbCloudAuth`
183
+### Xcode
184
168
-| Method | Description |
169
-|--------|-------------|
170
-| `login(email:password:)` | Authenticate with email and password |
171
-| `signup(email:password:)` | Create a new account |
172
-| `logout(accessToken:)` | Invalidate an access token |
173
-| `me(accessToken:)` | Fetch the authenticated user profile |
174
-| `removeAccount(accessToken:)` | Permanently delete the authenticated account |
175
-| `buildAppleAuthorizationRequest(redirectUri:state:)` | Build an Apple Sign-In authorization URL |
176
-| `parseAppleCallbackUrl(callbackUrl:expectedState:)` | Parse the Apple Sign-In callback |
177
-| `buildOidcAuthorizationRequest(oidcClientId:redirectUri:)` | Build an OIDC authorization URL with PKCE |
178
-| `parseOidcCallbackUrl(callbackUrl:)` | Parse the OIDC callback for code and state |
179
-| `exchangeOidcCode(oidcClientId:redirectUri:code:codeVerifier:)` | Exchange an authorization code for tokens |
180
-| `getOidcUserinfo(accessToken:tenantId:)` | Fetch OIDC user info |
185
+**File → Add Package Dependencies**, then enter:
186
182
-## Local Development
187
+```/dev/null/spm.txt#L1-L1
188
+https://github.com/smbcloudXYZ/smbcloud-auth-swift
189
+```
190
184
-Clone this repo alongside [smbcloud-cli](https://github.com/smbcloudXYZ/smbcloud-cli):
191
+### Swift Package Manager
192
193
+```/dev/null/package.swift#L1-L8
194
+dependencies: [
195
+ .package(url: "https://github.com/smbcloudXYZ/smbcloud-auth-swift", from: "0.4.1")
196
+],
197
+targets: [
198
+ .target(name: "MyApp", dependencies: [
199
+ .product(name: "SmbCloudAuth", package: "smbcloud-auth-swift")
200
+ ])
201
+]
202
```
203
+
204
+## Platforms
205
+
206
+| Platform | Minimum |
207
+|----------|---------|
208
+| iOS | 16.0 |
209
+| macOS | 14.0 |
210
+| tvOS | 16.0 |
211
+| visionOS | 1.0 |
212
+
213
+Hosted web login currently targets `ASWebAuthenticationSession` platforms: iOS, macOS, and visionOS. The shared session, storage, and userinfo helpers remain usable independently.
214
+
215
+## Local development
216
+
217
+Clone this repo alongside `smbcloud-cli`:
218
+
219
+```/dev/null/tree.txt#L1-L4
220
Repositories/
221
├── smbcloud-cli/
222
└── smbcloud-auth-swift/
223
```
224
192
-Build the XCFramework from the Rust source:
225
+Build the local XCFramework from the Rust source:
226
194
-```bash
195
-make ios # iOS device + simulator
196
-make macos # macOS (Apple silicon)
197
-make tvos # tvOS device + simulator
198
-make visionos # visionOS device + simulator
227
+```/dev/null/bash.txt#L1-L4
228
+make ios
229
+make macos
230
+make tvos
231
+make visionos
232
```
233
201
-This cross-compiles the Rust `smbcloud-auth-sdk-apple` crate, generates Swift bindings via [UniFFI](https://mozilla.github.io/uniffi-rs/), and packages everything into `SmbCloudAuthFramework.xcframework`.
234
+This cross-compiles the Rust Apple bindings, regenerates `Sources/SmbCloudAuth/smbcloud_auth.swift`, and writes the local XCFramework artifact used by SwiftPM.
235
203
-To point at a different checkout of the Rust repo:
236
+## Roadmap
237
205
-```bash
206
-make ios CLI_REPO=/path/to/smbcloud-cli
207
-```
238
+See [ROADMAP.md](ROADMAP.md).
239
209
-## How It Works
240
+## Security guidance
241
211
-This package wraps the [smbcloud-auth-sdk](https://github.com/smbcloudXYZ/smbcloud-cli/tree/development/crates/smbcloud-auth-sdk) Rust crate, compiled to a static library for each Apple platform and bridged to Swift via [UniFFI](https://mozilla.github.io/uniffi-rs/) proc-macros. The result is a native Swift API with full async/await support — no Objective-C bridging headers, no C interop in your app code.
242
+See [SECURITY.md](SECURITY.md).
243
244
## License
245
ROADMAP.md
new
+250
@@ -0,0 +1,250 @@
1
+# smbCloud Auth for Swift roadmap
2
+
3
+## Product goal
4
+
5
+Build the default Apple-platform SDK for smbCloud Auth.
6
+
7
+That means developers should be able to integrate authentication into an iPhone, iPad, Mac, Apple TV, or visionOS app with a clear, secure, Apple-native workflow.
8
+
9
+The product needs to be:
10
+
11
+- secure for public clients
12
+- easy to adopt
13
+- easy to document
14
+- easy to distribute via Swift Package Manager
15
+- opinionated around Apple-native auth UX
16
+
17
+## Core positioning
18
+
19
+`smbcloud-auth-swift` should be the **public-client SDK**.
20
+
21
+It should optimize for:
22
+
23
+- hosted auth
24
+- OIDC Authorization Code + PKCE
25
+- `ASWebAuthenticationSession`
26
+- token/session management
27
+- profile lookup
28
+- logout
29
+
30
+It should **not** optimize for embedding confidential-client secrets into shipped apps.
31
+
32
+## Product split
33
+
34
+### Public Apple SDK
35
+
36
+Repository:
37
+
38
+- `smbcloud-auth-swift`
39
+
40
+Audience:
41
+
42
+- iOS developers
43
+- macOS developers
44
+- tvOS developers
45
+- visionOS developers
46
+- open-source app maintainers
47
+- App Store product teams
48
+
49
+Primary value proposition:
50
+
51
+- secure hosted login for Apple platforms without shipping a client secret
52
+
53
+### Confidential / server-side SDKs
54
+
55
+Audience:
56
+
57
+- server-side Swift
58
+- backend services
59
+- internal tooling
60
+- trusted CI / automation
61
+
62
+These flows can continue to exist in Rust or other SDKs, but they should not be the main product story for the public Apple package.
63
+
64
+## Version roadmap
65
+
66
+## v0.x — foundation
67
+
68
+Status:
69
+
70
+- in progress
71
+
72
+What exists today:
73
+
74
+- low-level Rust-backed OIDC helpers
75
+- PKCE request generation
76
+- callback parsing
77
+- token exchange
78
+- userinfo
79
+- Apple Sign-In helpers
80
+
81
+What is missing:
82
+
83
+- polished Apple-native public API
84
+- browser/session orchestration
85
+- credentials storage
86
+- production-ready docs and examples
87
+
88
+## v1.0 — public-client MVP
89
+
90
+Status:
91
+
92
+- core API surface implemented in developer preview
93
+
94
+Goal:
95
+
96
+Ship the first sellable public-client SDK for Apple apps.
97
+
98
+### Implemented API surface
99
+
100
+- `SmbCloudWebAuth`
101
+- `SmbCloudSession`
102
+- `SmbCloudCredentialsManager`
103
+- `SmbCloudUserInfoClient`
104
+
105
+### Implemented capabilities
106
+
107
+- start hosted login with PKCE
108
+- open `ASWebAuthenticationSession`
109
+- handle callback URL
110
+- exchange authorization code
111
+- fetch user profile
112
+- local session persistence
113
+- local logout/session clearing helper
114
+
115
+### Remaining polish before stable v1
116
+
117
+- Quick Start for iOS
118
+- Quick Start for macOS
119
+- example app
120
+- migration guide for teams coming from proxy/native-form auth
121
+- release artifact parity across Apple platforms
122
+- logout / revocation story beyond local session clearing
123
+- security guidance refresh for the final API shape
124
+
125
+### MVP example shape
126
+
127
+```/dev/null/swift-example.swift#L1-L20
128
+let webAuth = SmbCloudWebAuth(
129
+ domain: "auth.smbcloud.xyz",
130
+ clientId: "YOUR_PUBLIC_CLIENT_ID",
131
+ redirectURL: URL(string: "myapp://auth/callback")!
132
+)
133
+
134
+let session = try await webAuth.login(
135
+ presenter: presenter,
136
+ scope: ["openid", "profile", "email"]
137
+)
138
+
139
+let user = try await webAuth.userInfo(accessToken: session.accessToken)
140
+try credentialsManager.store(session)
141
+```
142
+
143
+## v1.1 — production polish
144
+
145
+### Developer experience
146
+
147
+- better error types
148
+- strongly typed session models
149
+- clock-skew handling
150
+- refresh token helpers if supported
151
+- cleaner cancellation behavior
152
+- better logging hooks
153
+
154
+### Apple integrations
155
+
156
+- SwiftUI examples
157
+- UIKit examples
158
+- scene-aware callback handling
159
+- universal link guidance
160
+- custom scheme guidance
161
+
162
+### Operations
163
+
164
+- stable release workflow
165
+- signed XCFramework release process
166
+- checksum automation in Swift package repo
167
+
168
+## v1.2 — enterprise / BFF support docs
169
+
170
+This package should still recommend hosted auth for public apps, but document how teams can combine it with a backend/BFF when they want:
171
+
172
+- native login forms
173
+- session cookies
174
+- custom trust boundaries
175
+- server-managed auth orchestration
176
+
177
+This should be a **documented architecture path**, not the core package identity.
178
+
179
+## v2 — broader Apple auth platform
180
+
181
+Potential additions:
182
+
183
+- multi-tenant helpers
184
+- organizations/workspaces support
185
+- passkey/WebAuthn coordination if smbCloud Auth adds it
186
+- richer logout/session revocation flows
187
+- first-class secure token storage abstractions
188
+- diagnostic tools for callback and state mismatches
189
+
190
+## Packaging roadmap
191
+
192
+## Distribution model
193
+
194
+Primary distribution should remain:
195
+
196
+- Swift Package Manager
197
+
198
+Artifact strategy:
199
+
200
+- prebuilt XCFramework zip attached to GitHub releases
201
+- generated Swift bindings checked into repo
202
+- local path development mode for sibling `smbcloud-cli` checkout
203
+
204
+## CI/CD expectations
205
+
206
+- Apple platform build validation
207
+- XCFramework release automation
208
+- checksum publishing
209
+- release notes generation
210
+- tagged semver releases
211
+
212
+## Go-to-market
213
+
214
+## Who buys this
215
+
216
+Not literally as a separate SKU at first — the SDK makes smbCloud Auth itself more sellable.
217
+
218
+It helps sell smbCloud Auth to:
219
+
220
+- indie Apple developers
221
+- startups building Apple-first products
222
+- enterprise teams with internal Apple apps
223
+- agencies shipping branded apps for clients
224
+
225
+## What makes it sellable
226
+
227
+A sellable auth SDK is not just “it compiles”.
228
+It must communicate:
229
+
230
+- secure defaults
231
+- clear docs
232
+- familiar API design
233
+- low integration friction
234
+- a future-proof auth model
235
+
236
+## Product standard
237
+
238
+The product standard is:
239
+
240
+> a junior Apple developer should be able to integrate login in an afternoon without accidentally shipping a secret.
241
+
242
+## Success criteria
243
+
244
+The SDK is on the right track when:
245
+
246
+- public examples never require `app_secret`
247
+- the default sample app uses hosted auth + PKCE
248
+- docs explicitly forbid shipping confidential secrets in clients
249
+- SwiftUI sample apps feel natural
250
+- teams can adopt it without building their own auth glue layer first
SECURITY.md
new
+79
@@ -0,0 +1,79 @@
1
+# Security guidance for smbCloud Auth Swift
2
+
3
+## Public-client rule
4
+
5
+If your app ships to users, assume anything inside the client can be extracted.
6
+
7
+That includes:
8
+
9
+- values in `Info.plist`
10
+- values in bundled config files
11
+- environment-derived values copied into the app bundle
12
+- strings inside the binary
13
+- local cache or storage values
14
+
15
+Because of that:
16
+
17
+**Do not embed an smbCloud Auth `app_secret` inside a public Apple app.**
18
+
19
+## Recommended model for Apple apps
20
+
21
+Use:
22
+
23
+- hosted auth
24
+- system browser sessions
25
+- OIDC Authorization Code + PKCE
26
+
27
+On Apple platforms this generally means:
28
+
29
+- `ASWebAuthenticationSession`
30
+- a custom URL scheme or universal link callback
31
+- token exchange after the callback returns
32
+
33
+## Do not use embedded browser auth
34
+
35
+Avoid running auth inside embedded webviews such as:
36
+
37
+- `WKWebView`
38
+- other in-app embedded browser containers
39
+
40
+Use system browser-backed auth sessions instead.
41
+
42
+## If you need native login forms
43
+
44
+If your product requires a fully native email/password screen, do **not** solve that by shipping a secret in the app.
45
+
46
+Instead:
47
+
48
+- send credentials to your backend/BFF
49
+- keep smbCloud Auth confidential credentials on the server
50
+- let the backend proxy or orchestrate the auth flow
51
+
52
+## SDK positioning
53
+
54
+`smbcloud-auth-swift` should be used as a **public-client SDK**.
55
+
56
+That means the preferred long-term shape is:
57
+
58
+- hosted auth
59
+- PKCE
60
+- session/token helpers
61
+- userinfo/logout helpers
62
+
63
+not:
64
+
65
+- confidential-client secret-based login inside the shipped app
66
+
67
+## Operational guidance
68
+
69
+For development:
70
+
71
+- use separate dev credentials
72
+- prefer local-only configuration
73
+- never commit secrets into the repo
74
+
75
+For production:
76
+
77
+- use public-client identifiers only in the app
78
+- keep confidential secrets in server-side systems
79
+- audit example code to ensure no public sample embeds secrets
Sources/SmbCloudAuth/SmbCloudClientError.swift
new
+58
@@ -0,0 +1,58 @@
1
+import Foundation
2
+
3
+public enum SmbCloudClientError: Error, LocalizedError, Sendable {
4
+ case invalidBaseURL(String)
5
+ case invalidRedirectURL(String)
6
+ case invalidCallbackURL(String)
7
+ case missingAuthorizationCode
8
+ case missingAuthorizationState
9
+ case stateMismatch(expected: String, received: String)
10
+ case unsupportedPlatform
11
+ case loginInProgress
12
+ case cancelled
13
+ case authenticationFailed(String?)
14
+ case transportError(String)
15
+ case invalidResponse
16
+ case decodingFailed(String)
17
+ case apiError(statusCode: Int?, errorCode: Int?, message: String)
18
+
19
+ public var errorDescription: String? {
20
+ switch self {
21
+ case .invalidBaseURL(let message):
22
+ return message
23
+ case .invalidRedirectURL(let message):
24
+ return message
25
+ case .invalidCallbackURL(let message):
26
+ return message
27
+ case .missingAuthorizationCode:
28
+ return "The callback URL did not include an authorization code."
29
+ case .missingAuthorizationState:
30
+ return "The callback URL did not include an authorization state value."
31
+ case .stateMismatch(let expected, let received):
32
+ return "The callback state did not match. Expected \(expected), received \(received)."
33
+ case .unsupportedPlatform:
34
+ return "Hosted web authentication is not supported on this platform."
35
+ case .loginInProgress:
36
+ return "A web authentication session is already in progress."
37
+ case .cancelled:
38
+ return "The authentication session was cancelled."
39
+ case .authenticationFailed(let message):
40
+ return message ?? "The authentication session failed."
41
+ case .transportError(let message):
42
+ return message
43
+ case .invalidResponse:
44
+ return "The server returned an invalid response."
45
+ case .decodingFailed(let message):
46
+ return message
47
+ case .apiError(let statusCode, let errorCode, let message):
48
+ let parts = [
49
+ statusCode.map { "HTTP \($0)" },
50
+ errorCode.map { "API \($0)" },
51
+ message.nilIfEmpty,
52
+ ].compactMap { $0 }
53
+
54
+ return parts.isEmpty
55
+ ? "The smbCloud Auth API returned an error." : parts.joined(separator: " — ")
56
+ }
57
+ }
58
+}
Sources/SmbCloudAuth/SmbCloudCredentialsManager.swift
new
+156
@@ -0,0 +1,156 @@
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)
62
+ }
63
+ default:
64
+ throw keychainError(for: addStatus)
65
+ }
66
+ }
67
+
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
+ }
82
+
83
+ do {
84
+ return try JSONDecoder().decode(SmbCloudSession.self, from: data)
85
+ .storedRepresentation()
86
+ } catch {
87
+ throw SmbCloudCredentialsManagerError.decodingFailed(
88
+ "Failed to decode smbCloud session: \(error.localizedDescription)"
89
+ )
90
+ }
91
+ case errSecItemNotFound:
92
+ return nil
93
+ default:
94
+ throw keychainError(for: status)
95
+ }
96
+ }
97
+
98
+ public func currentValidSession(leeway: TimeInterval = 60) throws -> SmbCloudSession? {
99
+ guard let session = try current() else {
100
+ return nil
101
+ }
102
+
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
+ }
109
+
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)
117
+ }
118
+ }
119
+}
120
+
121
+extension SmbCloudCredentialsManager {
122
+ fileprivate static var defaultServiceName: String {
123
+ if let bundleIdentifier = Bundle.main.bundleIdentifier?.nilIfEmpty {
124
+ return "\(bundleIdentifier).smbcloud.auth"
125
+ }
126
+
127
+ return "xyz.smbcloud.auth"
128
+ }
129
+
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
+ ]
136
+
137
+ if let accessGroup {
138
+ query[kSecAttrAccessGroup as String] = accessGroup
139
+ }
140
+
141
+ return query
142
+ }
143
+
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
148
+ }
149
+
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)
155
+ }
156
+}
Sources/SmbCloudAuth/SmbCloudFallbackModels.swift
new
+30
@@ -0,0 +1,30 @@
1
+import Foundation
2
+
3
+#if !SMBCLOUD_AUTH_FFI_AVAILABLE
4
+ public enum SmbCloudEnvironment: Sendable {
5
+ case dev
6
+ case production
7
+ }
8
+
9
+ public struct SmbCloudUserInfo: Codable, Equatable, Hashable, Sendable {
10
+ public let sub: String
11
+ public let email: String?
12
+ public let emailVerified: Bool?
13
+ public let tenantId: UInt64?
14
+ public let tenantSlug: String?
15
+
16
+ public init(
17
+ sub: String,
18
+ email: String? = nil,
19
+ emailVerified: Bool? = nil,
20
+ tenantId: UInt64? = nil,
21
+ tenantSlug: String? = nil
22
+ ) {
23
+ self.sub = sub
24
+ self.email = email
25
+ self.emailVerified = emailVerified
26
+ self.tenantId = tenantId
27
+ self.tenantSlug = tenantSlug
28
+ }
29
+ }
30
+#endif
Sources/SmbCloudAuth/SmbCloudOpenIDConnectClient.swift
new
+332
@@ -0,0 +1,332 @@
1
+import CryptoKit
2
+import Foundation
3
+
4
+internal struct SmbCloudOpenIDConnectAuthorizationRequest {
5
+ let authorizeURL: URL
6
+ let state: String
7
+ let codeVerifier: String
8
+}
9
+
10
+internal struct SmbCloudTokenPayload: Decodable {
11
+ let accessToken: String
12
+ let tokenType: String
13
+ let expiresIn: Int32?
14
+ let refreshToken: String?
15
+ let scope: String?
16
+ let idToken: String?
17
+
18
+ private enum CodingKeys: String, CodingKey {
19
+ case accessToken = "access_token"
20
+ case tokenType = "token_type"
21
+ case expiresIn = "expires_in"
22
+ case refreshToken = "refresh_token"
23
+ case scope
24
+ case idToken = "id_token"
25
+ }
26
+}
27
+
28
+internal struct SmbCloudUserInfoPayload: Decodable {
29
+ let sub: String
30
+ let email: String?
31
+ let emailVerified: Bool?
32
+ let tenantId: UInt64?
33
+ let tenantSlug: String?
34
+
35
+ private enum CodingKeys: String, CodingKey {
36
+ case sub
37
+ case email
38
+ case emailVerified = "email_verified"
39
+ case tenantId = "tenant_id"
40
+ case tenantSlug = "tenant_slug"
41
+ }
42
+
43
+ var userInfo: SmbCloudUserInfo {
44
+ SmbCloudUserInfo(
45
+ sub: sub,
46
+ email: email,
47
+ emailVerified: emailVerified,
48
+ tenantId: tenantId,
49
+ tenantSlug: tenantSlug
50
+ )
51
+ }
52
+}
53
+
54
+internal struct SmbCloudAPIErrorPayload: Decodable {
55
+ let errorCode: Int?
56
+ let message: String
57
+
58
+ private enum CodingKeys: String, CodingKey {
59
+ case errorCode = "error_code"
60
+ case message
61
+ }
62
+}
63
+
64
+internal struct SmbCloudOAuthErrorPayload: Decodable {
65
+ let error: String
66
+ let errorDescription: String?
67
+
68
+ private enum CodingKeys: String, CodingKey {
69
+ case error
70
+ case errorDescription = "error_description"
71
+ }
72
+}
73
+
74
+internal struct SmbCloudOpenIDConnectClient {
75
+ static let defaultScopes = ["openid", "profile", "email"]
76
+
77
+ let baseURL: URL
78
+ let urlSession: URLSession
79
+
80
+ init(baseURL: URL, urlSession: URLSession = .shared) {
81
+ self.baseURL = baseURL
82
+ self.urlSession = urlSession
83
+ }
84
+
85
+ func authorizationRequest(
86
+ clientId: String,
87
+ redirectURL: URL,
88
+ scopes: [String],
89
+ audience: String?
90
+ ) throws -> SmbCloudOpenIDConnectAuthorizationRequest {
91
+ guard redirectURL.scheme?.nilIfEmpty != nil else {
92
+ throw SmbCloudClientError.invalidRedirectURL(
93
+ "The redirect URL must include a callback scheme."
94
+ )
95
+ }
96
+
97
+ let state = Self.randomURLSafeString(byteCount: 16)
98
+ let codeVerifier = Self.randomURLSafeString(byteCount: 32)
99
+ let codeChallenge = Self.sha256Base64URL(from: codeVerifier)
100
+ let normalizedScopes = Self.normalizedScopes(scopes)
101
+
102
+ guard
103
+ var components = URLComponents(
104
+ url: endpointURL(path: "oauth/authorize"),
105
+ resolvingAgainstBaseURL: false
106
+ )
107
+ else {
108
+ throw SmbCloudClientError.invalidBaseURL(
109
+ "Failed to build the smbCloud authorization URL."
110
+ )
111
+ }
112
+
113
+ var queryItems = [
114
+ URLQueryItem(name: "client_id", value: clientId),
115
+ URLQueryItem(name: "redirect_uri", value: redirectURL.absoluteString),
116
+ URLQueryItem(name: "response_type", value: "code"),
117
+ URLQueryItem(name: "scope", value: normalizedScopes.joined(separator: " ")),
118
+ URLQueryItem(name: "state", value: state),
119
+ URLQueryItem(name: "code_challenge", value: codeChallenge),
120
+ URLQueryItem(name: "code_challenge_method", value: "S256"),
121
+ ]
122
+
123
+ if let audience = audience?.nilIfEmpty {
124
+ queryItems.append(URLQueryItem(name: "audience", value: audience))
125
+ }
126
+
127
+ components.queryItems = queryItems
128
+
129
+ guard let authorizeURL = components.url else {
130
+ throw SmbCloudClientError.invalidBaseURL(
131
+ "Failed to build the smbCloud authorization URL."
132
+ )
133
+ }
134
+
135
+ return SmbCloudOpenIDConnectAuthorizationRequest(
136
+ authorizeURL: authorizeURL,
137
+ state: state,
138
+ codeVerifier: codeVerifier
139
+ )
140
+ }
141
+
142
+ func parseAuthorizationCode(from callbackURL: URL, expectedState: String) throws -> String {
143
+ guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)
144
+ else {
145
+ throw SmbCloudClientError.invalidCallbackURL(
146
+ "The callback URL was not valid."
147
+ )
148
+ }
149
+
150
+ let queryItems = components.queryItems ?? []
151
+
152
+ if let error = queryItems.firstValue(named: "error") {
153
+ let description = queryItems.firstValue(named: "error_description") ?? error
154
+ throw SmbCloudClientError.authenticationFailed(description)
155
+ }
156
+
157
+ guard let code = queryItems.firstValue(named: "code") else {
158
+ throw SmbCloudClientError.missingAuthorizationCode
159
+ }
160
+
161
+ guard let state = queryItems.firstValue(named: "state") else {
162
+ throw SmbCloudClientError.missingAuthorizationState
163
+ }
164
+
165
+ guard state == expectedState else {
166
+ throw SmbCloudClientError.stateMismatch(expected: expectedState, received: state)
167
+ }
168
+
169
+ return code
170
+ }
171
+
172
+ func exchangeCode(
173
+ clientId: String,
174
+ redirectURL: URL,
175
+ code: String,
176
+ codeVerifier: String
177
+ ) async throws -> SmbCloudSession {
178
+ var request = URLRequest(url: endpointURL(path: "oauth/token"))
179
+ request.httpMethod = "POST"
180
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
181
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
182
+ request.httpBody = Self.formEncodedData(
183
+ from: [
184
+ URLQueryItem(name: "grant_type", value: "authorization_code"),
185
+ URLQueryItem(name: "client_id", value: clientId),
186
+ URLQueryItem(name: "code", value: code),
187
+ URLQueryItem(name: "redirect_uri", value: redirectURL.absoluteString),
188
+ URLQueryItem(name: "code_verifier", value: codeVerifier),
189
+ ]
190
+ )
191
+
192
+ let payload: SmbCloudTokenPayload = try await send(
193
+ request, decodeAs: SmbCloudTokenPayload.self)
194
+ return SmbCloudSession(tokenResponse: payload)
195
+ }
196
+
197
+ func userInfo(accessToken: String, tenantId: String?) async throws -> SmbCloudUserInfo {
198
+ var request = URLRequest(url: endpointURL(path: "oauth/userinfo"))
199
+ request.httpMethod = "GET"
200
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
201
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
202
+
203
+ if let tenantId = tenantId?.nilIfEmpty {
204
+ request.setValue(tenantId, forHTTPHeaderField: "X-Smbcloud-Tenant-Id")
205
+ }
206
+
207
+ let payload: SmbCloudUserInfoPayload = try await send(
208
+ request, decodeAs: SmbCloudUserInfoPayload.self)
209
+ return payload.userInfo
210
+ }
211
+
212
+ func endpointURL(path: String) -> URL {
213
+ baseURL.appendingPathComponent(path)
214
+ }
215
+
216
+ func send<Response: Decodable>(_ request: URLRequest, decodeAs: Response.Type) async throws
217
+ -> Response
218
+ {
219
+ let data: Data
220
+ let response: URLResponse
221
+
222
+ do {
223
+ (data, response) = try await urlSession.data(for: request)
224
+ } catch {
225
+ throw SmbCloudClientError.transportError(error.localizedDescription)
226
+ }
227
+
228
+ guard let httpResponse = response as? HTTPURLResponse else {
229
+ throw SmbCloudClientError.invalidResponse
230
+ }
231
+
232
+ guard (200..<300).contains(httpResponse.statusCode) else {
233
+ throw apiError(from: data, statusCode: httpResponse.statusCode)
234
+ }
235
+
236
+ do {
237
+ return try JSONDecoder().decode(Response.self, from: data)
238
+ } catch {
239
+ throw SmbCloudClientError.decodingFailed(
240
+ "Failed to decode the smbCloud Auth response: \(error.localizedDescription)"
241
+ )
242
+ }
243
+ }
244
+
245
+ func apiError(from data: Data, statusCode: Int) -> SmbCloudClientError {
246
+ if let payload = try? JSONDecoder().decode(SmbCloudAPIErrorPayload.self, from: data) {
247
+ return .apiError(
248
+ statusCode: statusCode,
249
+ errorCode: payload.errorCode,
250
+ message: payload.message
251
+ )
252
+ }
253
+
254
+ if let payload = try? JSONDecoder().decode(SmbCloudOAuthErrorPayload.self, from: data) {
255
+ return .apiError(
256
+ statusCode: statusCode,
257
+ errorCode: nil,
258
+ message: payload.errorDescription?.nilIfEmpty ?? payload.error
259
+ )
260
+ }
261
+
262
+ let fallbackMessage =
263
+ String(data: data, encoding: .utf8)?.nilIfEmpty
264
+ ?? HTTPURLResponse.localizedString(forStatusCode: statusCode)
265
+
266
+ return .apiError(statusCode: statusCode, errorCode: nil, message: fallbackMessage)
267
+ }
268
+
269
+ static func normalizedScopes(from scope: String?) -> [String] {
270
+ normalizedScopes((scope ?? "").split(separator: " ").map(String.init))
271
+ }
272
+
273
+ static func normalizedScopes(_ scopes: [String]) -> [String] {
274
+ let sanitizedScopes = scopes.smbCloudUniqued()
275
+ return sanitizedScopes.isEmpty ? defaultScopes : sanitizedScopes
276
+ }
277
+
278
+ static func randomURLSafeString(byteCount: Int) -> String {
279
+ let randomBytes = Data((0..<byteCount).map { _ in UInt8.random(in: .min ... .max) })
280
+ return randomBytes.base64URLEncodedString()
281
+ }
282
+
283
+ static func sha256Base64URL(from value: String) -> String {
284
+ let digest = SHA256.hash(data: Data(value.utf8))
285
+ return Data(digest).base64URLEncodedString()
286
+ }
287
+
288
+ static func formEncodedData(from items: [URLQueryItem]) -> Data? {
289
+ var components = URLComponents()
290
+ components.queryItems = items
291
+ return components.percentEncodedQuery?.data(using: .utf8)
292
+ }
293
+}
294
+
295
+internal enum SmbCloudBaseURLFactory {
296
+ static func makeURL(for environment: SmbCloudEnvironment) -> URL {
297
+ switch environment {
298
+ case .dev:
299
+ return URL(string: "http://localhost:8088")!
300
+ case .production:
301
+ return URL(string: "https://api.smbcloud.xyz")!
302
+ }
303
+ }
304
+
305
+ static func makeURL(from domain: String) throws -> URL {
306
+ let trimmedDomain = domain.trimmingCharacters(in: .whitespacesAndNewlines)
307
+ guard trimmedDomain.isEmpty == false else {
308
+ throw SmbCloudClientError.invalidBaseURL("The smbCloud Auth domain cannot be empty.")
309
+ }
310
+
311
+ let candidate = trimmedDomain.contains("://") ? trimmedDomain : "https://\(trimmedDomain)"
312
+ guard let url = URL(string: candidate),
313
+ url.scheme?.nilIfEmpty != nil,
314
+ url.host?.nilIfEmpty != nil
315
+ else {
316
+ throw SmbCloudClientError.invalidBaseURL(
317
+ "The smbCloud Auth domain or base URL is invalid: \(domain)"
318
+ )
319
+ }
320
+
321
+ return url
322
+ }
323
+}
324
+
325
+extension Data {
326
+ func base64URLEncodedString() -> String {
327
+ base64EncodedString()
328
+ .replacingOccurrences(of: "+", with: "-")
329
+ .replacingOccurrences(of: "/", with: "_")
330
+ .replacingOccurrences(of: "=", with: "")
331
+ }
332
+}
Sources/SmbCloudAuth/SmbCloudSession.swift
new
+106
@@ -0,0 +1,106 @@
1
+import Foundation
2
+
3
+public struct SmbCloudSession: Codable, Equatable, Hashable, Sendable {
4
+ public let accessToken: String
5
+ public let refreshToken: String?
6
+ public let idToken: String?
7
+ public let tokenType: String
8
+ public let expiresAt: Date?
9
+ public let scopes: [String]
10
+
11
+ public init(
12
+ accessToken: String,
13
+ refreshToken: String? = nil,
14
+ idToken: String? = nil,
15
+ tokenType: String = "Bearer",
16
+ expiresAt: Date? = nil,
17
+ scopes: [String] = []
18
+ ) {
19
+ self.accessToken = accessToken
20
+ self.refreshToken = refreshToken
21
+ self.idToken = idToken
22
+ self.tokenType = tokenType
23
+ self.expiresAt = expiresAt
24
+ self.scopes = scopes
25
+ }
26
+
27
+ public var scope: String? {
28
+ guard scopes.isEmpty == false else {
29
+ return nil
30
+ }
31
+
32
+ return scopes.joined(separator: " ")
33
+ }
34
+
35
+ public var authorizationHeaderValue: String {
36
+ "\(tokenType) \(accessToken)"
37
+ }
38
+
39
+ public var isExpired: Bool {
40
+ isExpired(leeway: 0)
41
+ }
42
+
43
+ public func isExpired(leeway: TimeInterval = 0) -> Bool {
44
+ guard let expiresAt else {
45
+ return false
46
+ }
47
+
48
+ return expiresAt <= Date().addingTimeInterval(leeway)
49
+ }
50
+
51
+ public func isValid(leeway: TimeInterval = 60) -> Bool {
52
+ isExpired(leeway: leeway) == false
53
+ }
54
+}
55
+
56
+extension SmbCloudSession {
57
+ init(tokenResponse: SmbCloudTokenPayload, now: Date = Date()) {
58
+ let expiresAt = tokenResponse.expiresIn.map { now.addingTimeInterval(TimeInterval($0)) }
59
+ self.init(
60
+ accessToken: tokenResponse.accessToken,
61
+ refreshToken: tokenResponse.refreshToken?.nilIfEmpty,
62
+ idToken: tokenResponse.idToken?.nilIfEmpty,
63
+ tokenType: tokenResponse.tokenType,
64
+ expiresAt: expiresAt,
65
+ scopes: SmbCloudOpenIDConnectClient.normalizedScopes(from: tokenResponse.scope)
66
+ )
67
+ }
68
+
69
+ func storedRepresentation() -> SmbCloudSession {
70
+ SmbCloudSession(
71
+ accessToken: accessToken,
72
+ refreshToken: refreshToken?.nilIfEmpty,
73
+ idToken: idToken?.nilIfEmpty,
74
+ tokenType: tokenType,
75
+ expiresAt: expiresAt,
76
+ scopes: scopes.smbCloudUniqued()
77
+ )
78
+ }
79
+}
80
+
81
+extension String {
82
+ var nilIfEmpty: String? {
83
+ trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
84
+ }
85
+}
86
+
87
+extension Sequence where Element == String {
88
+ func smbCloudUniqued() -> [String] {
89
+ var seen = Set<String>()
90
+
91
+ return compactMap { value in
92
+ let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
93
+ guard trimmedValue.isEmpty == false else {
94
+ return nil
95
+ }
96
+
97
+ return seen.insert(trimmedValue).inserted ? trimmedValue : nil
98
+ }
99
+ }
100
+}
101
+
102
+extension Sequence where Element == URLQueryItem {
103
+ func firstValue(named name: String) -> String? {
104
+ first(where: { $0.name == name })?.value?.nilIfEmpty
105
+ }
106
+}
Sources/SmbCloudAuth/SmbCloudUserInfoClient.swift
new
+33
@@ -0,0 +1,33 @@
1
+import Foundation
2
+
3
+public struct SmbCloudUserInfoClient {
4
+ private let client: SmbCloudOpenIDConnectClient
5
+
6
+ public init(environment: SmbCloudEnvironment = .production) {
7
+ self.client = SmbCloudOpenIDConnectClient(
8
+ baseURL: SmbCloudBaseURLFactory.makeURL(for: environment)
9
+ )
10
+ }
11
+
12
+ public init(baseURL: URL) {
13
+ self.client = SmbCloudOpenIDConnectClient(baseURL: baseURL)
14
+ }
15
+
16
+ public init(domain: String) throws {
17
+ self.client = SmbCloudOpenIDConnectClient(
18
+ baseURL: try SmbCloudBaseURLFactory.makeURL(from: domain)
19
+ )
20
+ }
21
+
22
+ public func userInfo(accessToken: String, tenantId: String? = nil) async throws
23
+ -> SmbCloudUserInfo
24
+ {
25
+ try await client.userInfo(accessToken: accessToken, tenantId: tenantId)
26
+ }
27
+
28
+ public func userInfo(session: SmbCloudSession, tenantId: String? = nil) async throws
29
+ -> SmbCloudUserInfo
30
+ {
31
+ try await userInfo(accessToken: session.accessToken, tenantId: tenantId)
32
+ }
33
+}
Sources/SmbCloudAuth/SmbCloudWebAuth.swift
new
+204
@@ -0,0 +1,204 @@
1
+import Foundation
2
+
3
+#if canImport(AuthenticationServices) && (os(iOS) || os(macOS) || os(visionOS))
4
+ import AuthenticationServices
5
+
6
+ public typealias SmbCloudPresentationAnchor = ASPresentationAnchor
7
+#endif
8
+
9
+public final class SmbCloudWebAuth: @unchecked Sendable {
10
+ public static let defaultScopes: [String] = ["openid", "profile", "email"]
11
+
12
+ private let clientId: String
13
+ private let redirectURL: URL
14
+ private let client: SmbCloudOpenIDConnectClient
15
+ private let userInfoClient: SmbCloudUserInfoClient
16
+
17
+ #if canImport(AuthenticationServices) && (os(iOS) || os(macOS) || os(visionOS))
18
+ @MainActor
19
+ private var activeAuthenticationSession: ASWebAuthenticationSession?
20
+
21
+ @MainActor
22
+ private var activePresentationContextProvider: SmbCloudPresentationContextProvider?
23
+ #endif
24
+
25
+ public init(
26
+ environment: SmbCloudEnvironment = .production,
27
+ clientId: String,
28
+ redirectURL: URL
29
+ ) {
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)
35
+ }
36
+
37
+ 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)
42
+ }
43
+
44
+ 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)
47
+ }
48
+
49
+ public func userInfo(accessToken: String, tenantId: String? = nil) async throws
50
+ -> SmbCloudUserInfo
51
+ {
52
+ try await userInfoClient.userInfo(accessToken: accessToken, tenantId: tenantId)
53
+ }
54
+
55
+ public func userInfo(session: SmbCloudSession, tenantId: String? = nil) async throws
56
+ -> SmbCloudUserInfo
57
+ {
58
+ try await userInfo(accessToken: session.accessToken, tenantId: tenantId)
59
+ }
60
+
61
+ public func clearSession(credentialsManager: SmbCloudCredentialsManager? = nil) throws {
62
+ try credentialsManager?.clear()
63
+ }
64
+
65
+ public func logout(credentialsManager: SmbCloudCredentialsManager? = nil) throws {
66
+ try clearSession(credentialsManager: credentialsManager)
67
+ }
68
+}
69
+
70
+#if canImport(AuthenticationServices) && (os(iOS) || os(macOS) || os(visionOS))
71
+ extension SmbCloudWebAuth {
72
+ @MainActor
73
+ public func login(
74
+ presentationAnchorProvider: @escaping () -> SmbCloudPresentationAnchor,
75
+ scopes: [String] = SmbCloudWebAuth.defaultScopes,
76
+ audience: String? = nil,
77
+ prefersEphemeralSession: Bool = false,
78
+ credentialsManager: SmbCloudCredentialsManager? = nil
79
+ ) async throws -> SmbCloudSession {
80
+ guard activeAuthenticationSession == nil else {
81
+ throw SmbCloudClientError.loginInProgress
82
+ }
83
+
84
+ let authorizationRequest = try client.authorizationRequest(
85
+ clientId: clientId,
86
+ redirectURL: redirectURL,
87
+ scopes: scopes,
88
+ audience: audience
89
+ )
90
+
91
+ let callbackScheme = redirectURL.scheme?.nilIfEmpty
92
+ guard let callbackScheme else {
93
+ throw SmbCloudClientError.invalidRedirectURL(
94
+ "The redirect URL must include a callback scheme."
95
+ )
96
+ }
97
+
98
+ let presentationContextProvider = SmbCloudPresentationContextProvider(
99
+ presentationAnchorProvider: presentationAnchorProvider
100
+ )
101
+
102
+ defer {
103
+ activeAuthenticationSession = nil
104
+ activePresentationContextProvider = nil
105
+ }
106
+
107
+ let callbackURL = try await withTaskCancellationHandler {
108
+ try await withCheckedThrowingContinuation {
109
+ (continuation: CheckedContinuation<URL, Error>) in
110
+ let session = ASWebAuthenticationSession(
111
+ url: authorizationRequest.authorizeURL,
112
+ callbackURLScheme: callbackScheme
113
+ ) { callbackURL, error in
114
+ if let error {
115
+ continuation.resume(throwing: Self.authenticationError(from: error))
116
+ return
117
+ }
118
+
119
+ guard let callbackURL else {
120
+ continuation.resume(
121
+ throwing: SmbCloudClientError.authenticationFailed(
122
+ "The authentication session completed without a callback URL."
123
+ )
124
+ )
125
+ return
126
+ }
127
+
128
+ continuation.resume(returning: callbackURL)
129
+ }
130
+
131
+ session.prefersEphemeralWebBrowserSession = prefersEphemeralSession
132
+ session.presentationContextProvider = presentationContextProvider
133
+
134
+ activePresentationContextProvider = presentationContextProvider
135
+ activeAuthenticationSession = session
136
+
137
+ guard session.start() else {
138
+ activeAuthenticationSession = nil
139
+ activePresentationContextProvider = nil
140
+ continuation.resume(
141
+ throwing: SmbCloudClientError.authenticationFailed(
142
+ "Failed to start the smbCloud web authentication session."
143
+ )
144
+ )
145
+ return
146
+ }
147
+ }
148
+ } onCancel: { [weak self] in
149
+ guard let self else {
150
+ return
151
+ }
152
+
153
+ Task { @MainActor in
154
+ self.activeAuthenticationSession?.cancel()
155
+ self.activeAuthenticationSession = nil
156
+ self.activePresentationContextProvider = nil
157
+ }
158
+ }
159
+
160
+ let authorizationCode = try client.parseAuthorizationCode(
161
+ from: callbackURL,
162
+ expectedState: authorizationRequest.state
163
+ )
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
177
+ }
178
+
179
+ private static func authenticationError(from error: Error) -> SmbCloudClientError {
180
+ if let authenticationError = error as? ASWebAuthenticationSessionError,
181
+ authenticationError.code == .canceledLogin
182
+ {
183
+ return .cancelled
184
+ }
185
+
186
+ return .authenticationFailed(error.localizedDescription)
187
+ }
188
+ }
189
+
190
+ private final class SmbCloudPresentationContextProvider:
191
+ NSObject,
192
+ ASWebAuthenticationPresentationContextProviding
193
+ {
194
+ private let presentationAnchorProvider: () -> SmbCloudPresentationAnchor
195
+
196
+ init(presentationAnchorProvider: @escaping () -> SmbCloudPresentationAnchor) {
197
+ self.presentationAnchorProvider = presentationAnchorProvider
198
+ }
199
+
200
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
201
+ presentationAnchorProvider()
202
+ }
203
+ }
204
+#endif