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