Add CI and release workflows, docs, and example app

- Add GitHub Actions workflows for CI and release validation - Add migration and quick start guides for iOS and macOS - Add release process documentation - Add a packaged SwiftUI example app for hosted login - Update .gitignore for example app build artifacts - Refactor Package.swift for local FFI product toggling - Add basic unit tests for SmbCloudAuth - Update README, ROADMAP, and SECURITY docs for v1 MVP completion

Seto Elkahfi committed May 17, 2026 at 10:57 UTC 4acabf487c33be1369e148a913208cd974f11a36
20 files changed +1075 -67
.github/workflows/ci.yml new
+46
@@ -0,0 +1,46 @@
1 +name: CI
2 +
3 +on:
4 + push:
5 + tags-ignore:
6 + - "v*.*.*"
7 + pull_request:
8 +
9 +permissions:
10 + contents: read
11 +
12 +jobs:
13 + verify:
14 + name: Verify Swift package and example
15 + runs-on: macos-latest
16 +
17 + steps:
18 + - name: Checkout
19 + uses: actions/checkout@v6
20 +
21 + - name: Show Swift version
22 + run: swift --version
23 +
24 + - name: Verify package
25 + run: make verify
26 +
27 + verify-apple-destinations:
28 + name: Verify Apple destination build (${{ matrix.platform }})
29 + runs-on: macos-latest
30 + strategy:
31 + fail-fast: false
32 + matrix:
33 + platform:
34 + - ios
35 + - tvos
36 + - visionos
37 +
38 + steps:
39 + - name: Checkout
40 + uses: actions/checkout@v6
41 +
42 + - name: Show Swift version
43 + run: swift --version
44 +
45 + - name: Verify destination build
46 + run: make verify-apple-destination PLATFORM=${{ matrix.platform }}
.github/workflows/release.yml new
+79
@@ -0,0 +1,79 @@
1 +name: Release
2 +
3 +on:
4 + push:
5 + tags:
6 + - "v*.*.*"
7 + workflow_dispatch:
8 + inputs:
9 + tag:
10 + description: "Release tag (e.g. v1.0.0)"
11 + required: true
12 +
13 +permissions:
14 + contents: write
15 +
16 +jobs:
17 + verify:
18 + name: Verify release package and example
19 + runs-on: macos-latest
20 +
21 + steps:
22 + - name: Checkout
23 + uses: actions/checkout@v6
24 + with:
25 + ref: ${{ github.event.inputs.tag || github.ref }}
26 +
27 + - name: Show Swift version
28 + run: swift --version
29 +
30 + - name: Verify package
31 + run: make verify
32 +
33 + verify-apple-destinations:
34 + name: Verify release Apple destination build (${{ matrix.platform }})
35 + needs: verify
36 + runs-on: macos-latest
37 + strategy:
38 + fail-fast: false
39 + matrix:
40 + platform:
41 + - ios
42 + - tvos
43 + - visionos
44 +
45 + steps:
46 + - name: Checkout
47 + uses: actions/checkout@v6
48 + with:
49 + ref: ${{ github.event.inputs.tag || github.ref }}
50 +
51 + - name: Show Swift version
52 + run: swift --version
53 +
54 + - name: Verify destination build
55 + run: make verify-apple-destination PLATFORM=${{ matrix.platform }}
56 +
57 + github-release:
58 + name: Create GitHub release
59 + needs:
60 + - verify
61 + - verify-apple-destinations
62 + runs-on: ubuntu-latest
63 +
64 + steps:
65 + - name: Resolve tag
66 + id: tag
67 + shell: bash
68 + run: |
69 + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
70 + echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
71 + else
72 + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
73 + fi
74 +
75 + - name: Create release
76 + uses: softprops/action-gh-release@v2
77 + with:
78 + tag_name: ${{ steps.tag.outputs.tag }}
79 + generate_release_notes: true
.gitignore
+2
@@ -1,3 +1,5 @@
1 smbcloud_authFFI.xcframework/
2 .local/
3 .build/
4 +Examples/HostedLoginExample/.build/
5 +Examples/HostedLoginExample/.swiftpm/
Docs/Migration-From-Proxy-Native-Forms.md new
+112
@@ -0,0 +1,112 @@
1 +# Migration guide — from proxy/native-form auth to hosted auth
2 +
3 +This guide is for teams currently using:
4 +
5 +- a backend or BFF proxy for login/signup
6 +- native email/password forms in the app
7 +- confidential smbCloud Auth credentials stored on the server
8 +
9 +and who want to move Apple apps toward the hosted public-client SDK model.
10 +
11 +## When to migrate
12 +
13 +Move to hosted auth when:
14 +
15 +- your Apple app is a public client
16 +- you want to stop maintaining auth proxy glue in the app layer
17 +- you want browser/session based login with PKCE
18 +- you do not need a custom native email/password screen as the primary flow
19 +
20 +Keep the proxy/BFF model when:
21 +
22 +- your product requires native credential forms
23 +- your backend owns broader session orchestration
24 +- you need server-managed trust boundaries beyond public-client hosted auth
25 +
26 +## Old model
27 +
28 +1. user enters email and password into the app
29 +2. app sends credentials to your backend
30 +3. backend uses confidential smbCloud Auth credentials
31 +4. backend returns an app-safe response
32 +
33 +## Hosted auth model
34 +
35 +1. app calls `SmbCloudWebAuth.login(...)`
36 +2. SDK opens hosted auth in `ASWebAuthenticationSession`
37 +3. user completes login in the system browser session
38 +4. SDK validates callback `state` and exchanges the code with PKCE
39 +5. app persists the returned `SmbCloudSession`
40 +6. app loads profile data with `userInfo(...)`
41 +
42 +## Migration plan
43 +
44 +### 1. Keep server-side auth for native forms if still needed
45 +
46 +Do not remove your proxy if the product still depends on native credential entry.
47 +Hosted auth and proxy-based auth can coexist during migration.
48 +
49 +### 2. Add a public client in smbCloud Auth
50 +
51 +The Apple app should use a public client identifier and a callback URL such as:
52 +
53 +- `myapp://auth/callback`
54 +
55 +Do not copy confidential secrets into the app.
56 +
57 +### 3. Replace app-side login orchestration
58 +
59 +Replace custom login glue with:
60 +
61 +- `SmbCloudWebAuth`
62 +- `SmbCloudCredentialsManager`
63 +- `SmbCloudUserInfoClient` if you want standalone profile fetches
64 +
65 +### 4. Move local session storage to Keychain
66 +
67 +Persist `SmbCloudSession` with `SmbCloudCredentialsManager` instead of keeping access tokens in plaintext storage.
68 +
69 +### 5. Update your UX copy
70 +
71 +Hosted auth changes the user experience from a native email/password form to a system browser-backed sign-in flow.
72 +Prepare copy, onboarding, and support docs accordingly.
73 +
74 +## What changes in app code
75 +
76 +### Before
77 +
78 +- app owns email/password UI
79 +- app talks to your backend for auth
80 +- backend owns smbCloud Auth credentials
81 +
82 +### After
83 +
84 +- app launches hosted auth via `SmbCloudWebAuth.login(...)`
85 +- SDK handles PKCE and callback validation
86 +- app stores `SmbCloudSession`
87 +- app fetches user info with the access token
88 +
89 +## Security benefits
90 +
91 +- no confidential smbCloud Auth secret inside the shipped app
92 +- no embedded webview login flow
93 +- PKCE protects the public-client authorization code flow
94 +- system browser sessions align with Apple platform expectations
95 +
96 +## What does not change
97 +
98 +If you still need native forms, a BFF/proxy remains the correct architecture.
99 +This SDK does not try to replace secure server-side confidential flows for those cases.
100 +
101 +## Recommended rollout
102 +
103 +1. introduce hosted auth for new sign-ins
104 +2. keep proxy-based flows for legacy/native-form users if necessary
105 +3. migrate session restoration to `SmbCloudCredentialsManager`
106 +4. retire app-specific auth glue once the hosted path is the default
107 +
108 +## Related docs
109 +
110 +- `Docs/QuickStart-iOS.md`
111 +- `Docs/QuickStart-macOS.md`
112 +- `SECURITY.md`
Docs/QuickStart-iOS.md new
+165
@@ -0,0 +1,165 @@
1 +# Quick Start — iOS
2 +
3 +This guide shows the hosted smbCloud Auth flow for an iPhone or iPad app using:
4 +
5 +- `SmbCloudWebAuth`
6 +- `SmbCloudCredentialsManager`
7 +- `SmbCloudUserInfoClient`
8 +
9 +## 1. Add the package
10 +
11 +Add `https://github.com/smbcloudXYZ/smbcloud-auth-swift` in Xcode or Swift Package Manager.
12 +
13 +## 2. Register a callback URL scheme
14 +
15 +Your app needs a custom callback URL such as:
16 +
17 +- `myapp://auth/callback`
18 +
19 +Add the URL scheme in your app target settings so iOS can route the hosted auth callback back into the app.
20 +
21 +## 3. Create a small auth store
22 +
23 +Use one public client ID from smbCloud Auth.
24 +Do not embed an `app_secret` in the app.
25 +
26 +```swift
27 +import SmbCloudAuth
28 +import SwiftUI
29 +
30 +@MainActor
31 +final class AuthenticationStore: ObservableObject {
32 + @Published private(set) var session: SmbCloudSession?
33 + @Published private(set) var email: String?
34 + @Published private(set) var errorMessage: String?
35 + @Published private(set) var isLoading = false
36 +
37 + private let webAuth = try! SmbCloudWebAuth(
38 + domain: "api.smbcloud.xyz",
39 + clientId: "YOUR_PUBLIC_CLIENT_ID",
40 + redirectURL: URL(string: "myapp://auth/callback")!
41 + )
42 + private let credentialsManager = SmbCloudCredentialsManager(
43 + service: "com.example.myapp.smbcloud-auth"
44 + )
45 +
46 + func restoreSession() {
47 + do {
48 + session = try credentialsManager.currentValidSession()
49 + } catch {
50 + errorMessage = error.localizedDescription
51 + }
52 + }
53 +
54 + func signIn(anchor: @escaping () -> SmbCloudPresentationAnchor) async {
55 + isLoading = true
56 + errorMessage = nil
57 + defer { isLoading = false }
58 +
59 + do {
60 + let session = try await webAuth.login(
61 + presentationAnchorProvider: anchor,
62 + credentialsManager: credentialsManager
63 + )
64 + self.session = session
65 +
66 + let user = try await webAuth.userInfo(session: session)
67 + email = user.email
68 + } catch {
69 + errorMessage = error.localizedDescription
70 + }
71 + }
72 +
73 + func signOut() {
74 + do {
75 + try webAuth.clearSession(credentialsManager: credentialsManager)
76 + session = nil
77 + email = nil
78 + errorMessage = nil
79 + } catch {
80 + errorMessage = error.localizedDescription
81 + }
82 + }
83 +}
84 +```
85 +
86 +## 4. Provide a presentation anchor
87 +
88 +For SwiftUI on iOS, the simplest approach is to return the current key window.
89 +
90 +```swift
91 +import UIKit
92 +
93 +func currentPresentationAnchor() -> UIWindow {
94 + UIApplication.shared.connectedScenes
95 + .compactMap { $0 as? UIWindowScene }
96 + .flatMap(\.windows)
97 + .first { $0.isKeyWindow } ?? UIWindow()
98 +}
99 +```
100 +
101 +## 5. Build a simple screen
102 +
103 +```swift
104 +import SwiftUI
105 +
106 +struct ContentView: View {
107 + @StateObject private var authStore = AuthenticationStore()
108 +
109 + var body: some View {
110 + VStack(spacing: 16) {
111 + if let session = authStore.session {
112 + Text("Signed in")
113 + Text(session.accessToken)
114 + .font(.footnote.monospaced())
115 + .lineLimit(2)
116 + if let email = authStore.email {
117 + Text(email)
118 + }
119 + Button("Sign Out") {
120 + authStore.signOut()
121 + }
122 + } else {
123 + Button("Continue with smbCloud") {
124 + Task {
125 + await authStore.signIn {
126 + currentPresentationAnchor()
127 + }
128 + }
129 + }
130 + }
131 +
132 + if let errorMessage = authStore.errorMessage {
133 + Text(errorMessage)
134 + .foregroundStyle(.red)
135 + .multilineTextAlignment(.center)
136 + }
137 + }
138 + .padding()
139 + .task {
140 + authStore.restoreSession()
141 + }
142 + }
143 +}
144 +```
145 +
146 +## 6. What happens during login
147 +
148 +1. `SmbCloudWebAuth` creates a PKCE authorization request.
149 +2. `ASWebAuthenticationSession` opens the hosted smbCloud login page.
150 +3. smbCloud Auth redirects to your app callback URL.
151 +4. The SDK validates `state`, exchanges the authorization code, and returns `SmbCloudSession`.
152 +5. `SmbCloudCredentialsManager` stores the session in Keychain.
153 +
154 +## Notes
155 +
156 +- Prefer an explicit Keychain `service` value per app.
157 +- `logout()` currently clears local stored credentials. It does not revoke tokens remotely.
158 +- Hosted web login requires `ASWebAuthenticationSession`, so use iOS 16+.
159 +- If your product needs native email/password forms, send those credentials to your backend or BFF instead of embedding confidential credentials in the app.
160 +
161 +## Related docs
162 +
163 +- `Docs/QuickStart-macOS.md`
164 +- `Docs/Migration-From-Proxy-Native-Forms.md`
165 +- `SECURITY.md`
Docs/QuickStart-macOS.md new
+132
@@ -0,0 +1,132 @@
1 +# Quick Start — macOS
2 +
3 +This guide shows hosted smbCloud Auth in a macOS app using `SmbCloudWebAuth`.
4 +
5 +## 1. Add the package
6 +
7 +Add `https://github.com/smbcloudXYZ/smbcloud-auth-swift` to your macOS app.
8 +
9 +## 2. Configure a callback URL
10 +
11 +Use a custom callback URL such as:
12 +
13 +- `mymacapp://auth/callback`
14 +
15 +Register that URL scheme for your app so the hosted auth callback returns to the application.
16 +
17 +## 3. Build a small store
18 +
19 +```swift
20 +import AppKit
21 +import SmbCloudAuth
22 +import SwiftUI
23 +
24 +@MainActor
25 +final class AuthenticationStore: ObservableObject {
26 + @Published private(set) var session: SmbCloudSession?
27 + @Published private(set) var email: String?
28 + @Published private(set) var errorMessage: String?
29 +
30 + private let webAuth = try! SmbCloudWebAuth(
31 + domain: "api.smbcloud.xyz",
32 + clientId: "YOUR_PUBLIC_CLIENT_ID",
33 + redirectURL: URL(string: "mymacapp://auth/callback")!
34 + )
35 + private let credentialsManager = SmbCloudCredentialsManager(
36 + service: "com.example.mymacapp.smbcloud-auth"
37 + )
38 +
39 + func restoreSession() {
40 + do {
41 + session = try credentialsManager.currentValidSession()
42 + } catch {
43 + errorMessage = error.localizedDescription
44 + }
45 + }
46 +
47 + func signIn() async {
48 + do {
49 + let session = try await webAuth.login(
50 + presentationAnchorProvider: {
51 + NSApplication.shared.keyWindow ?? NSWindow()
52 + },
53 + credentialsManager: credentialsManager
54 + )
55 + self.session = session
56 + let user = try await webAuth.userInfo(session: session)
57 + email = user.email
58 + } catch {
59 + errorMessage = error.localizedDescription
60 + }
61 + }
62 +
63 + func signOut() {
64 + do {
65 + try webAuth.clearSession(credentialsManager: credentialsManager)
66 + session = nil
67 + email = nil
68 + } catch {
69 + errorMessage = error.localizedDescription
70 + }
71 + }
72 +}
73 +```
74 +
75 +## 4. Use it from SwiftUI
76 +
77 +```swift
78 +import SwiftUI
79 +
80 +struct ContentView: View {
81 + @StateObject private var authStore = AuthenticationStore()
82 +
83 + var body: some View {
84 + VStack(spacing: 12) {
85 + if let email = authStore.email {
86 + Text("Signed in as \(email)")
87 + } else if authStore.session != nil {
88 + Text("Signed in")
89 + } else {
90 + Text("Signed out")
91 + }
92 +
93 + HStack {
94 + Button("Sign In") {
95 + Task {
96 + await authStore.signIn()
97 + }
98 + }
99 +
100 + Button("Sign Out") {
101 + authStore.signOut()
102 + }
103 + .disabled(authStore.session == nil)
104 + }
105 +
106 + if let errorMessage = authStore.errorMessage {
107 + Text(errorMessage)
108 + .foregroundStyle(.red)
109 + .multilineTextAlignment(.center)
110 + }
111 + }
112 + .padding()
113 + .frame(width: 360)
114 + .task {
115 + authStore.restoreSession()
116 + }
117 + }
118 +}
119 +```
120 +
121 +## Notes
122 +
123 +- Supply a real public client ID from smbCloud Auth.
124 +- Do not ship an `app_secret` in the macOS app.
125 +- `clearSession()` and `logout()` currently clear local stored credentials only.
126 +- If you need native email/password forms, keep confidential auth credentials on your backend.
127 +
128 +## Related docs
129 +
130 +- `Docs/QuickStart-iOS.md`
131 +- `Docs/Migration-From-Proxy-Native-Forms.md`
132 +- `SECURITY.md`
Docs/Release.md new
+77
@@ -0,0 +1,77 @@
1 +# Release process
2 +
3 +`smbcloud-auth-swift` now ships as a source-based Swift Package.
4 +
5 +That means the public `SmbCloudAuth` product does not need a remote XCFramework artifact to be published.
6 +The package is consumable directly from the Git tag.
7 +
8 +## What gets released
9 +
10 +Public product:
11 +
12 +- `SmbCloudAuth`
13 +
14 +Optional local development product:
15 +
16 +- `SmbCloudAuthFFI`
17 +
18 +`SmbCloudAuthFFI` is for sibling-repo development when you build the local Rust + UniFFI artifacts yourself.
19 +It is not required for normal package consumers.
20 +
21 +## CI validation
22 +
23 +GitHub Actions validates release candidates with:
24 +
25 +- `make verify`
26 +- `make verify-apple-destination PLATFORM=ios`
27 +- `make verify-apple-destination PLATFORM=tvos`
28 +- `make verify-apple-destination PLATFORM=visionos`
29 +
30 +That covers:
31 +
32 +- the main Swift package build
33 +- the main Swift package tests
34 +- the packaged macOS example app build
35 +- generic Apple destination builds for iOS, tvOS, and visionOS
36 +
37 +## Tagging a release
38 +
39 +Create and push a semver tag:
40 +
41 +```/dev/null/bash.txt#L1-L2
42 +git tag v1.0.0
43 +git push origin v1.0.0
44 +```
45 +
46 +The release workflow will:
47 +
48 +1. check out the tagged revision
49 +2. run `make verify`
50 +3. create a GitHub Release for the tag
51 +
52 +## Manual release dispatch
53 +
54 +You can also run the `Release` workflow manually and provide a tag such as `v1.0.0`.
55 +
56 +## Local pre-release verification
57 +
58 +Before tagging, run:
59 +
60 +```/dev/null/bash.txt#L1-L2
61 +make verify
62 +make verify-apple-destinations
63 +```
64 +
65 +## Local optional FFI development
66 +
67 +If you are working alongside `smbcloud-cli`, you can generate the optional local UniFFI/XCFramework layer with:
68 +
69 +```/dev/null/bash.txt#L1-L4
70 +make ios
71 +make macos
72 +make tvos
73 +make visionos
74 +```
75 +
76 +That enables the local `SmbCloudAuthFFI` product for development.
77 +The public release flow does not depend on those artifacts.
Examples/HostedLoginExample/Package.swift new
+21
@@ -0,0 +1,21 @@
1 +// swift-tools-version:6.2
2 +import PackageDescription
3 +
4 +let package = Package(
5 + name: "HostedLoginExample",
6 + platforms: [
7 + .macOS(.v14)
8 + ],
9 + dependencies: [
10 + .package(path: "../..")
11 + ],
12 + targets: [
13 + .executableTarget(
14 + name: "HostedLoginExample",
15 + dependencies: [
16 + .product(name: "SmbCloudAuth", package: "smbcloud-auth-swift")
17 + ],
18 + path: "Sources/HostedLoginExample"
19 + )
20 + ]
21 +)
Examples/HostedLoginExample/README.md new
+34
@@ -0,0 +1,34 @@
1 +# Hosted Login Example
2 +
3 +This folder contains a small SwiftUI-oriented example of the v1 MVP API surface:
4 +
5 +- `SmbCloudWebAuth`
6 +- `SmbCloudSession`
7 +- `SmbCloudCredentialsManager`
8 +- `SmbCloudUserInfoClient`
9 +
10 +## Files
11 +
12 +- `Package.swift` — standalone example package manifest
13 +- `Sources/HostedLoginExample/HostedLoginExampleApp.swift` — SwiftUI app entry point
14 +- `Sources/HostedLoginExample/AuthenticationStore.swift` — minimal observable auth state container
15 +- `Sources/HostedLoginExample/HostedLoginView.swift` — simple signed-in / signed-out SwiftUI view
16 +
17 +## Configuration you must replace
18 +
19 +Before using the example, replace:
20 +
21 +- `YOUR_PUBLIC_CLIENT_ID`
22 +- `hostedloginexample://auth/callback`
23 +- Keychain service name
24 +
25 +## Build locally
26 +
27 +```/dev/null/bash.txt#L1-L1
28 +swift build --package-path Examples/HostedLoginExample
29 +```
30 +
31 +## Security note
32 +
33 +Use a public client ID only.
34 +Do not embed an smbCloud Auth `app_secret` in a shipped Apple app.
Examples/HostedLoginExample/Sources/HostedLoginExample/AuthenticationStore.swift new
+71
@@ -0,0 +1,71 @@
1 +import SmbCloudAuth
2 +import SwiftUI
3 +
4 +@MainActor
5 +final class AuthenticationStore: ObservableObject {
6 + @Published private(set) var session: SmbCloudSession?
7 + @Published private(set) var userEmail: String?
8 + @Published private(set) var errorMessage: String?
9 + @Published private(set) var isLoading = false
10 +
11 + private let webAuth: SmbCloudWebAuth?
12 + private let credentialsManager = SmbCloudCredentialsManager(
13 + service: "com.example.hosted-login-example.smbcloud-auth"
14 + )
15 +
16 + init() {
17 + do {
18 + self.webAuth = try SmbCloudWebAuth(
19 + domain: "api.smbcloud.xyz",
20 + clientId: "YOUR_PUBLIC_CLIENT_ID",
21 + redirectURL: URL(string: "hostedloginexample://auth/callback")!
22 + )
23 + } catch {
24 + self.webAuth = nil
25 + self.errorMessage = error.localizedDescription
26 + }
27 + }
28 +
29 + func restoreSession() {
30 + do {
31 + session = try credentialsManager.currentValidSession()
32 + } catch {
33 + errorMessage = error.localizedDescription
34 + }
35 + }
36 +
37 + func signIn(anchorProvider: @escaping () -> SmbCloudPresentationAnchor) async {
38 + guard let webAuth else {
39 + errorMessage = errorMessage ?? "The smbCloud web auth client could not be created."
40 + return
41 + }
42 +
43 + isLoading = true
44 + errorMessage = nil
45 + defer { isLoading = false }
46 +
47 + do {
48 + let session = try await webAuth.login(
49 + presentationAnchorProvider: anchorProvider,
50 + credentialsManager: credentialsManager
51 + )
52 + self.session = session
53 +
54 + let user = try await webAuth.userInfo(session: session)
55 + userEmail = user.email
56 + } catch {
57 + errorMessage = error.localizedDescription
58 + }
59 + }
60 +
61 + func signOut() {
62 + do {
63 + try webAuth?.clearSession(credentialsManager: credentialsManager)
64 + session = nil
65 + userEmail = nil
66 + errorMessage = nil
67 + } catch {
68 + errorMessage = error.localizedDescription
69 + }
70 + }
71 +}
Examples/HostedLoginExample/Sources/HostedLoginExample/HostedLoginExampleApp.swift new
+14
@@ -0,0 +1,14 @@
1 +import SwiftUI
2 +
3 +@main
4 +struct HostedLoginExampleApp: App {
5 + var body: some Scene {
6 + WindowGroup {
7 + HostedLoginView(
8 + presentationAnchorProvider: {
9 + currentPresentationAnchor()
10 + }
11 + )
12 + }
13 + }
14 +}
Examples/HostedLoginExample/Sources/HostedLoginExample/HostedLoginView.swift new
+64
@@ -0,0 +1,64 @@
1 +import AppKit
2 +import SmbCloudAuth
3 +import SwiftUI
4 +
5 +struct HostedLoginView: View {
6 + @StateObject private var authenticationStore = AuthenticationStore()
7 + let presentationAnchorProvider: () -> SmbCloudPresentationAnchor
8 +
9 + var body: some View {
10 + VStack(spacing: 16) {
11 + Text("smbCloud Hosted Login Example")
12 + .font(.title2)
13 +
14 + if let userEmail = authenticationStore.userEmail {
15 + Text(userEmail)
16 + .font(.headline)
17 + } else if authenticationStore.session != nil {
18 + Text("Signed in")
19 + .font(.headline)
20 + } else {
21 + Text("Signed out")
22 + .font(.headline)
23 + }
24 +
25 + if let session = authenticationStore.session {
26 + Text(session.accessToken)
27 + .font(.footnote.monospaced())
28 + .lineLimit(2)
29 + }
30 +
31 + HStack {
32 + Button(authenticationStore.isLoading ? "Loading…" : "Continue with smbCloud") {
33 + Task {
34 + await authenticationStore.signIn(
35 + anchorProvider: presentationAnchorProvider
36 + )
37 + }
38 + }
39 + .disabled(authenticationStore.isLoading)
40 +
41 + Button("Sign Out") {
42 + authenticationStore.signOut()
43 + }
44 + .disabled(authenticationStore.session == nil)
45 + }
46 +
47 + if let errorMessage = authenticationStore.errorMessage {
48 + Text(errorMessage)
49 + .foregroundStyle(.red)
50 + .multilineTextAlignment(.center)
51 + }
52 + }
53 + .padding()
54 + .frame(width: 460)
55 + .task {
56 + authenticationStore.restoreSession()
57 + }
58 + }
59 +}
60 +
61 +@MainActor
62 +func currentPresentationAnchor() -> NSWindow {
63 + NSApplication.shared.keyWindow ?? NSWindow()
64 +}
Makefile
+55 -9
@@ -21,26 +21,35 @@ 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
24 +SWIFT_GLUE := $(SWIFT_REPO)/Sources/SmbCloudAuthFFI/smbcloud_auth.swift
25 +LOCAL_FFI_MARKER := $(LOCAL_DIR)/use-local-ffi
26
27 LIB_NAME := libsmbcloud_auth.a
28 SUPPORTED_PLATFORMS := ios macos tvos visionos
29
29 -.PHONY: help platform ios macos tvos visionos validate build-bindgen prepare generate-swift stage-framework clean
30 +.PHONY: help platform ios macos tvos visionos validate build-bindgen prepare generate-swift stage-framework activate-local-ffi deactivate-local-ffi clean verify verify-example verify-apple-destinations verify-apple-destination
31
32 help:
33 @printf '%s\n' \
34 'SmbCloudAuth Swift — local Apple-platform builds' \
35 '' \
36 'Usage:' \
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' \
37 + ' make ios Build smbcloud_authFFI.xcframework for iOS device + simulator' \
38 + ' make macos Build smbcloud_authFFI.xcframework for macOS (Apple silicon)' \
39 + ' make tvos Build smbcloud_authFFI.xcframework for tvOS device + simulator' \
40 + ' make visionos Build smbcloud_authFFI.xcframework for visionOS device + simulator' \
41 + ' make verify Build + test the main Swift package and build the example app package' \
42 + ' make verify-example Build the packaged macOS example app' \
43 + ' make verify-apple-destinations Build the public package for iOS, tvOS, and visionOS generic destinations' \
44 + ' make deactivate-local-ffi Hide the optional local SmbCloudAuthFFI product' \
45 '' \
46 'Artifacts:' \
47 ' smbcloud_authFFI.xcframework/' \
43 - ' Sources/SmbCloudAuth/smbcloud_auth.swift (regenerated from the local Rust build)' \
48 + ' Sources/SmbCloudAuthFFI/smbcloud_auth.swift (regenerated from the local Rust build)' \
49 + '' \
50 + 'Notes:' \
51 + ' The public SmbCloudAuth product is pure Swift and always available.' \
52 + ' Local Rust/UniFFI builds enable the optional SmbCloudAuthFFI product.' \
53 '' \
54 'Optional overrides:' \
55 ' CLI_REPO=/absolute/path/to/smbcloud-cli'
@@ -61,8 +70,8 @@ build-bindgen: validate
70 cargo $(RUST_STABLE) build --manifest-path "$(APPLE_CRATE_MANIFEST)" --features bindgen --bin uniffi-bindgen --release
71
72 prepare:
64 - @rm -rf "$(FRAMEWORK_DIR)" "$(GENERATED_DIR)" "$(HEADERS_DIR)" "$(FRAMEWORK_STAGING_DIR)"
65 - @mkdir -p "$(GENERATED_DIR)" "$(HEADERS_DIR)" "$(FRAMEWORK_STAGING_DIR)"
73 + @rm -rf "$(FRAMEWORK_DIR)" "$(GENERATED_DIR)" "$(HEADERS_DIR)" "$(FRAMEWORK_STAGING_DIR)" "$(LOCAL_FFI_MARKER)"
74 + @mkdir -p "$(GENERATED_DIR)" "$(HEADERS_DIR)" "$(FRAMEWORK_STAGING_DIR)" "$(SWIFT_REPO)/Sources/SmbCloudAuthFFI"
75
76 generate-swift:
77 @test -n "$(BINDGEN_INPUT)" || { echo "BINDGEN_INPUT is required"; exit 1; }
@@ -71,6 +80,15 @@ generate-swift:
80 cp "$(GENERATED_DIR)/smbcloud_authFFI.h" "$(HEADERS_DIR)/smbcloud_authFFI.h"
81 @echo "Updated $(SWIFT_GLUE)"
82
83 +activate-local-ffi:
84 + @mkdir -p "$(LOCAL_DIR)"
85 + @touch "$(LOCAL_FFI_MARKER)"
86 + @echo "Enabled local SmbCloudAuthFFI product"
87 +
88 +deactivate-local-ffi:
89 + @rm -f "$(LOCAL_FFI_MARKER)"
90 + @echo "Disabled local SmbCloudAuthFFI product"
91 +
92 stage-framework:
93 @test -n "$(LIB_INPUT)" || { echo "LIB_INPUT is required"; exit 1; }
94 @test -n "$(FRAMEWORK_OUTPUT)" || { echo "FRAMEWORK_OUTPUT is required"; exit 1; }
@@ -116,6 +134,7 @@ ios: build-bindgen prepare
134 -framework "$(FRAMEWORK_STAGING_DIR)/ios/$(SLICE_FRAMEWORK_NAME)" \
135 -framework "$(FRAMEWORK_STAGING_DIR)/ios-sim/$(SLICE_FRAMEWORK_NAME)" \
136 -output "$(FRAMEWORK_DIR)"
137 + $(MAKE) activate-local-ffi
138 @echo "Built $(FRAMEWORK_DIR) for iOS"
139
140 macos: build-bindgen prepare
@@ -125,6 +144,7 @@ macos: build-bindgen prepare
144 xcodebuild -create-xcframework \
145 -framework "$(FRAMEWORK_STAGING_DIR)/macos/$(SLICE_FRAMEWORK_NAME)" \
146 -output "$(FRAMEWORK_DIR)"
147 + $(MAKE) activate-local-ffi
148 @echo "Built $(FRAMEWORK_DIR) for macOS"
149
150 tvos: build-bindgen prepare
@@ -137,6 +157,7 @@ tvos: build-bindgen prepare
157 -framework "$(FRAMEWORK_STAGING_DIR)/tvos/$(SLICE_FRAMEWORK_NAME)" \
158 -framework "$(FRAMEWORK_STAGING_DIR)/tvos-sim/$(SLICE_FRAMEWORK_NAME)" \
159 -output "$(FRAMEWORK_DIR)"
160 + $(MAKE) activate-local-ffi
161 @echo "Built $(FRAMEWORK_DIR) for tvOS"
162
163 visionos: build-bindgen prepare
@@ -149,8 +170,33 @@ visionos: build-bindgen prepare
170 -framework "$(FRAMEWORK_STAGING_DIR)/visionos/$(SLICE_FRAMEWORK_NAME)" \
171 -framework "$(FRAMEWORK_STAGING_DIR)/visionos-sim/$(SLICE_FRAMEWORK_NAME)" \
172 -output "$(FRAMEWORK_DIR)"
173 + $(MAKE) activate-local-ffi
174 @echo "Built $(FRAMEWORK_DIR) for visionOS"
175
176 +verify:
177 + swift build
178 + swift test
179 + $(MAKE) verify-example
180 +
181 +verify-example:
182 + swift build --package-path Examples/HostedLoginExample
183 +
184 +verify-apple-destinations:
185 + $(MAKE) verify-apple-destination PLATFORM=ios
186 + $(MAKE) verify-apple-destination PLATFORM=tvos
187 + $(MAKE) verify-apple-destination PLATFORM=visionos
188 +
189 +verify-apple-destination:
190 + @test -n "$(PLATFORM)" || { echo "Set PLATFORM=ios|tvos|visionos"; exit 1; }
191 + @case "$(PLATFORM)" in \
192 + ios) destination="generic/platform=iOS"; derived_data="$(LOCAL_DIR)/verify-ios" ;; \
193 + tvos) destination="generic/platform=tvOS"; derived_data="$(LOCAL_DIR)/verify-tvos" ;; \
194 + visionos) destination="generic/platform=visionOS"; derived_data="$(LOCAL_DIR)/verify-visionos" ;; \
195 + *) echo "Unsupported PLATFORM='$(PLATFORM)'. Expected one of: ios tvos visionos"; exit 1 ;; \
196 + esac; \
197 + xcodebuild -quiet -scheme SmbCloudAuth -destination "$${destination}" -derivedDataPath "$${derived_data}" build
198 +
199 clean:
200 rm -rf "$(FRAMEWORK_DIR)" "$(LOCAL_DIR)"
201 + rm -f "$(SWIFT_GLUE)"
202 @echo "Removed local SmbCloudAuth Swift build artifacts"
Package.swift
+45 -23
@@ -3,53 +3,75 @@ import Foundation
3 import PackageDescription
4
5 let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
6 +let fileManager = FileManager.default
7 +
8 let localFrameworkPath = "smbcloud_authFFI.xcframework"
9 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 -
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")] : []
10 +let localFfiMarkerPath = packageRoot.appendingPathComponent(".local/use-local-ffi").path
11 +let ffiSwiftShimPath = packageRoot.appendingPathComponent(
12 + "Sources/SmbCloudAuthFFI/smbcloud_auth.swift"
13 +).path
14 +
15 +let shouldEnableLocalFfi =
16 + fileManager.fileExists(atPath: localFfiMarkerPath)
17 + && fileManager.fileExists(atPath: localFrameworkAbsolutePath)
18 + && fileManager.fileExists(atPath: ffiSwiftShimPath)
19
20 let targets: [Target] = {
22 - var targets: [Target] = []
21 + var targets: [Target] = [
22 + .target(
23 + name: "SmbCloudAuth",
24 + path: "Sources/SmbCloudAuth"
25 + )
26 + ]
27
24 - if hasUsableLocalFramework {
28 + if shouldEnableLocalFfi {
29 targets.append(
30 .binaryTarget(
31 name: "smbcloud_authFFI",
32 path: localFrameworkPath
33 )
34 )
35 +
36 + targets.append(
37 + .target(
38 + name: "SmbCloudAuthFFI",
39 + dependencies: [.target(name: "smbcloud_authFFI")],
40 + path: "Sources/SmbCloudAuthFFI"
41 + )
42 + )
43 }
44
45 targets.append(
34 - .target(
35 - name: "SmbCloudAuth",
36 - dependencies: ffiDependencies,
37 - path: "Sources/SmbCloudAuth",
38 - exclude: excludedSourceFiles,
39 - swiftSettings: swiftSettings
46 + .testTarget(
47 + name: "SmbCloudAuthTests",
48 + dependencies: ["SmbCloudAuth"],
49 + path: "Tests/SmbCloudAuthTests"
50 )
51 )
52
53 return targets
54 }()
55
56 +let products: [Product] = {
57 + var products: [Product] = [
58 + .library(name: "SmbCloudAuth", targets: ["SmbCloudAuth"])
59 + ]
60 +
61 + if shouldEnableLocalFfi {
62 + products.append(
63 + .library(name: "SmbCloudAuthFFI", targets: ["SmbCloudAuthFFI"])
64 + )
65 + }
66 +
67 + return products
68 +}()
69 +
70 let package = Package(
71 name: "SmbCloudAuth",
72 platforms: [
73 .iOS(.v16), .macOS(.v14), .tvOS(.v16), .visionOS(.v1),
74 ],
51 - products: [
52 - .library(name: "SmbCloudAuth", targets: ["SmbCloudAuth"])
53 - ],
75 + products: products,
76 targets: targets
77 )
README.md
+27 -4
@@ -31,7 +31,7 @@
31
32 ## Current status
33
34 -This package is still a **developer preview**, but the v1 public-client MVP surface now exists.
34 +This package is still a **developer preview**, but the v1 public-client MVP surface, docs, tests, packaged example, and release validation workflows now exist.
35
36 Today it provides:
37
@@ -46,7 +46,8 @@ Today it provides:
46 - userinfo lookup
47 - local session clearing/logout helper
48
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.
49 +The public `SmbCloudAuth` product is now a stable pure-Swift surface for hosted auth on Apple platforms.
50 +The low-level generated UniFFI layer remains available as an optional local `SmbCloudAuthFFI` product when you build the Rust/XCFramework side during sibling-repo development.
51
52 ## Security model
53
@@ -222,7 +223,7 @@ Repositories/
223 └── smbcloud-auth-swift/
224 ```
225
225 -Build the local XCFramework from the Rust source:
226 +Build the optional local XCFramework + UniFFI shim from the Rust source:
227
228 ```/dev/null/bash.txt#L1-L4
229 make ios
@@ -231,7 +232,29 @@ make tvos
232 make visionos
233 ```
234
234 -This cross-compiles the Rust Apple bindings, regenerates `Sources/SmbCloudAuth/smbcloud_auth.swift`, and writes the local XCFramework artifact used by SwiftPM.
235 +This cross-compiles the Rust Apple bindings, regenerates `Sources/SmbCloudAuthFFI/smbcloud_auth.swift`, and writes the local XCFramework artifact used by the optional local `SmbCloudAuthFFI` product.
236 +
237 +The public `SmbCloudAuth` package can still build without these local Rust artifacts.
238 +
239 +## Guides and examples
240 +
241 +- [Quick Start — iOS](Docs/QuickStart-iOS.md)
242 +- [Quick Start — macOS](Docs/QuickStart-macOS.md)
243 +- [Migration from proxy/native-form auth](Docs/Migration-From-Proxy-Native-Forms.md)
244 +- [Hosted login example](Examples/HostedLoginExample/README.md)
245 +- [Release process](Docs/Release.md)
246 +
247 +You can build the packaged macOS example app locally with:
248 +
249 +```/dev/null/bash.txt#L1-L1
250 +swift build --package-path Examples/HostedLoginExample
251 +```
252 +
253 +And you can validate generic Apple destination builds locally with:
254 +
255 +```/dev/null/bash.txt#L1-L1
256 +make verify-apple-destinations
257 +```
258
259 ## Roadmap
260
ROADMAP.md
+11 -6
@@ -89,7 +89,9 @@ What is missing:
89
90 Status:
91
92 -- core API surface implemented in developer preview
92 +- v1 MVP complete in developer preview form
93 +- core API surface implemented
94 +- docs, tests, packaged example app target, CI, and release workflow implemented
95
96 Goal:
97
@@ -112,15 +114,18 @@ Ship the first sellable public-client SDK for Apple apps.
114 - local session persistence
115 - local logout/session clearing helper
116
115 -### Remaining polish before stable v1
117 +### Completed MVP docs and examples
118
119 - Quick Start for iOS
120 - Quick Start for macOS
119 -- example app
121 +- hosted login example
122 - 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
123 +- security guidance refresh for the current API shape
124 +
125 +### Stable-v1 follow-up work
126 +
127 +- release artifact parity across Apple platforms for the optional local `SmbCloudAuthFFI` product
128 +- logout / revocation story beyond local session clearing if smbCloud Auth exposes a public-client-safe revocation flow
129
130 ### MVP example shape
131
SECURITY.md
+16
@@ -23,12 +23,14 @@ Use:
23 - hosted auth
24 - system browser sessions
25 - OIDC Authorization Code + PKCE
26 +- Keychain-backed local session storage
27
28 On Apple platforms this generally means:
29
30 - `ASWebAuthenticationSession`
31 - a custom URL scheme or universal link callback
32 - token exchange after the callback returns
33 +- persisting `SmbCloudSession` with `SmbCloudCredentialsManager`
34
35 ## Do not use embedded browser auth
36
@@ -64,6 +66,18 @@ not:
66
67 - confidential-client secret-based login inside the shipped app
68
69 +## Local credential storage
70 +
71 +If you persist sessions locally, prefer Keychain storage.
72 +
73 +The SDK provides `SmbCloudCredentialsManager` for that purpose.
74 +Use an explicit per-app `service` name so multiple apps or test targets do not accidentally share the same stored session namespace.
75 +
76 +## Logout semantics
77 +
78 +The current SDK `logout()` helper is a local session-clearing helper.
79 +It clears persisted credentials through `SmbCloudCredentialsManager`, but it does not yet perform remote token revocation.
80 +
81 ## Operational guidance
82
83 For development:
@@ -71,9 +85,11 @@ For development:
85 - use separate dev credentials
86 - prefer local-only configuration
87 - never commit secrets into the repo
88 +- use a development-specific Keychain service name if you store sessions locally
89
90 For production:
91
92 - use public-client identifiers only in the app
93 - keep confidential secrets in server-side systems
94 - audit example code to ensure no public sample embeds secrets
95 +- review callback URL handling and Keychain service naming before shipping
Sources/SmbCloudAuth/SmbCloudFallbackModels.swift
+23 -25
@@ -1,30 +1,28 @@
1 import Foundation
2
3 -#if !SMBCLOUD_AUTH_FFI_AVAILABLE
4 - public enum SmbCloudEnvironment: Sendable {
5 - case dev
6 - case production
7 - }
3 +public enum SmbCloudEnvironment: Sendable {
4 + case dev
5 + case production
6 +}
7
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?
8 +public struct SmbCloudUserInfo: Codable, Equatable, Hashable, Sendable {
9 + public let sub: String
10 + public let email: String?
11 + public let emailVerified: Bool?
12 + public let tenantId: UInt64?
13 + public let tenantSlug: String?
14
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 - }
15 + public init(
16 + sub: String,
17 + email: String? = nil,
18 + emailVerified: Bool? = nil,
19 + tenantId: UInt64? = nil,
20 + tenantSlug: String? = nil
21 + ) {
22 + self.sub = sub
23 + self.email = email
24 + self.emailVerified = emailVerified
25 + self.tenantId = tenantId
26 + self.tenantSlug = tenantSlug
27 }
30 -#endif
28 +}
Sources/SmbCloudAuthFFI/smbcloud_auth.swift renamed
Tests/SmbCloudAuthTests/SmbCloudAuthTests.swift new
+81
@@ -0,0 +1,81 @@
1 +import Foundation
2 +import XCTest
3 +
4 +@testable import SmbCloudAuth
5 +
6 +final class SmbCloudAuthTests: XCTestCase {
7 + func testSessionScopeJoinsScopesWithSpaces() {
8 + let session = SmbCloudSession(
9 + accessToken: "token",
10 + scopes: ["openid", "profile", "email"]
11 + )
12 +
13 + XCTAssertEqual(session.scope, "openid profile email")
14 + XCTAssertEqual(session.authorizationHeaderValue, "Bearer token")
15 + }
16 +
17 + func testSessionValidityReflectsExpiryDate() {
18 + let futureSession = SmbCloudSession(
19 + accessToken: "future-token",
20 + expiresAt: Date().addingTimeInterval(300)
21 + )
22 + let pastSession = SmbCloudSession(
23 + accessToken: "past-token",
24 + expiresAt: Date().addingTimeInterval(-300)
25 + )
26 +
27 + XCTAssertTrue(futureSession.isValid())
28 + XCTAssertFalse(futureSession.isExpired)
29 + XCTAssertFalse(pastSession.isValid(leeway: 0))
30 + XCTAssertTrue(pastSession.isExpired)
31 + }
32 +
33 + func testSessionCodableRoundTripPreservesValues() throws {
34 + let originalSession = SmbCloudSession(
35 + accessToken: "access-token",
36 + refreshToken: "refresh-token",
37 + idToken: "id-token",
38 + tokenType: "Bearer",
39 + expiresAt: Date(timeIntervalSince1970: 1_750_000_000),
40 + scopes: ["openid", "profile", "email"]
41 + )
42 +
43 + let encodedSession = try JSONEncoder().encode(originalSession)
44 + let decodedSession = try JSONDecoder().decode(SmbCloudSession.self, from: encodedSession)
45 +
46 + XCTAssertEqual(decodedSession, originalSession)
47 + }
48 +
49 + func testCredentialsManagerUsesExplicitServiceWhenProvided() {
50 + let manager = SmbCloudCredentialsManager(service: "xyz.smbcloud.tests", account: "tester")
51 +
52 + XCTAssertEqual(manager.service, "xyz.smbcloud.tests")
53 + XCTAssertEqual(manager.account, "tester")
54 + }
55 +
56 + func testInvalidUserInfoDomainThrowsInvalidBaseUrlError() {
57 + XCTAssertThrowsError(try SmbCloudUserInfoClient(domain: " ")) { error in
58 + guard case SmbCloudClientError.invalidBaseURL = error else {
59 + return XCTFail("Expected invalidBaseURL error, got: \(error)")
60 + }
61 + }
62 + }
63 +
64 + func testInvalidWebAuthDomainThrowsInvalidBaseUrlError() {
65 + XCTAssertThrowsError(
66 + try SmbCloudWebAuth(
67 + domain: " ",
68 + clientId: "public-client-id",
69 + redirectURL: URL(string: "myapp://auth/callback")!
70 + )
71 + ) { error in
72 + guard case SmbCloudClientError.invalidBaseURL = error else {
73 + return XCTFail("Expected invalidBaseURL error, got: \(error)")
74 + }
75 + }
76 + }
77 +
78 + func testDefaultScopesMatchOidcDefaults() {
79 + XCTAssertEqual(SmbCloudWebAuth.defaultScopes, ["openid", "profile", "email"])
80 + }
81 +}