| 1 | # AuthCore target surface — pinned to `smbcloud-auth-sdk` |
| 2 | |
| 3 | **Status:** design spec for correcting `AuthCore`. |
| 4 | |
| 5 | ## Principle |
| 6 | |
| 7 | `AuthCore` is a **faithful Swift port of the canonical client SDK core, |
| 8 | `smbcloud-cli/crates/smbcloud-auth-sdk`** — the same crate wrapped by the |
| 9 | `-apple`, `-py`, and `-wasm` bindings. The Rust crate is the **contract |
| 10 | authority**; `smbcloud-model` is the **DTO/type authority**; the Rails |
| 11 | `smbcloud-api` is the ultimate wire truth. AuthCore implements the contract |
| 12 | natively in Swift (no Rust/UniFFI dependency, so it builds cleanly on Linux |
| 13 | server-side and on Apple) and is held in lockstep by a **shared conformance |
| 14 | suite** run against the same fixtures/contract as the other bindings. |
| 15 | |
| 16 | > The previously shipped `AuthCore` (`SmbCloudAuthClient` doing `oauth/authorize` |
| 17 | > PKCE) corresponds to *only* the `oidc.rs` module and was mislabeled as the |
| 18 | > whole SDK — it omitted the primary tenant credential flow. This spec restores |
| 19 | > the full surface. |
| 20 | |
| 21 | Module map (`smbcloud-auth-sdk/src/*` → AuthCore): |
| 22 | |
| 23 | | Rust module | AuthCore surface | |
| 24 | |------------------------|----------------------------------------------------| |
| 25 | | `client_credentials.rs`| `ClientCredentials`, base-URL builder | |
| 26 | | `login.rs` | `AuthCoreClient.login` | |
| 27 | | `signup.rs` | `AuthCoreClient.signup` | |
| 28 | | `me.rs` | `AuthCoreClient.me` | |
| 29 | | `logout.rs` | `AuthCoreClient.logout` | |
| 30 | | `remove.rs` | `AuthCoreClient.remove` | |
| 31 | | `apple.rs` | `AuthCoreClient.buildAppleAuthorizationRequest` / `parseAppleCallbackURL` | |
| 32 | | `oidc.rs` | `OIDC.*` (public-client PKCE) | |
| 33 | |
| 34 | ## Shared types |
| 35 | |
| 36 | ```swift |
| 37 | /// Mirrors smbcloud_network::environment::Environment. |
| 38 | public enum SmbCloudEnvironment: Sendable { |
| 39 | case dev // http · localhost:8088 |
| 40 | case production // https · api.smbcloud.xyz |
| 41 | |
| 42 | public var apiProtocol: String { self == .dev ? "http" : "https" } |
| 43 | public var apiHost: String { self == .dev ? "localhost:8088" : "api.smbcloud.xyz" } |
| 44 | } |
| 45 | |
| 46 | /// Mirrors client_credentials::ClientCredentials. Sent as the `client_id` and |
| 47 | /// `client_secret` QUERY params on every /v1/client/* request. |
| 48 | public struct ClientCredentials: Sendable { |
| 49 | public let appId: String |
| 50 | public let appSecret: String |
| 51 | public init(appId: String, appSecret: String) |
| 52 | } |
| 53 | |
| 54 | /// Mirrors smbcloud_model::error_codes::{ErrorResponse, ErrorCode}. |
| 55 | public struct SmbCloudError: Error, Sendable { |
| 56 | public let code: SmbCloudErrorCode |
| 57 | public let message: String |
| 58 | } |
| 59 | |
| 60 | public enum SmbCloudErrorCode: Int, Sendable { |
| 61 | case unknown = 0, parseError = 1, networkError = 2, inputError = 3, |
| 62 | missingConfig = 4, cancel = 5 |
| 63 | case unauthorized = 100, invalidParams = 101 |
| 64 | case emailNotFound = 1000, emailNotVerified = 1001, emailConfirmationFailed = 1002, |
| 65 | passwordNotSet = 1003, gitHubEmailNotConnected = 1004, emailAlreadyExist = 1005, |
| 66 | invalidPassword = 1006 |
| 67 | case projectNotFound = 2000, unsupportedRunner = 2001 |
| 68 | } |
| 69 | ``` |
| 70 | |
| 71 | ## Tenant credential flow (primary) |
| 72 | |
| 73 | ```swift |
| 74 | public struct AuthCoreClient: Sendable { |
| 75 | public init(environment: SmbCloudEnvironment, credentials: ClientCredentials) |
| 76 | |
| 77 | // login.rs → POST /v1/client/users/sign_in (body {user:{email,password}}) |
| 78 | public func login(username: String, password: String) async throws -> AccountStatus |
| 79 | |
| 80 | // signup.rs → POST /v1/client/users (body {user:{email,password}}) |
| 81 | public func signup(email: String, password: String) async throws -> SignupResult |
| 82 | |
| 83 | // me.rs → GET /v1/client/me |
| 84 | public func me(accessToken: String) async throws -> User |
| 85 | |
| 86 | // logout.rs → DELETE /v1/client/users/sign_out |
| 87 | public func logout(accessToken: String) async throws |
| 88 | |
| 89 | // remove.rs → DELETE /v1/client/me |
| 90 | public func remove(accessToken: String) async throws |
| 91 | |
| 92 | // Account recovery — ahead of smbcloud-auth-sdk (backend has these; the |
| 93 | // canonical crate doesn't wrap them yet). Upstream for parity. Each returns |
| 94 | // the server's user-facing message. |
| 95 | public func requestPasswordReset(email: String) async throws -> String // POST users/reset_password |
| 96 | public func completePasswordReset(token:password:passwordConfirmation:) async throws -> String // POST users/reset_password/complete |
| 97 | public func resendConfirmation(email: String) async throws -> String // POST users/resend_confirmation |
| 98 | } |
| 99 | |
| 100 | /// login.rs result, mapped by network::request_login. |
| 101 | public enum AccountStatus: Sendable { |
| 102 | case notFound |
| 103 | case ready(accessToken: String) // full Authorization header value (includes "Bearer ") |
| 104 | case incomplete(status: AccountErrorCode) |
| 105 | } |
| 106 | |
| 107 | /// Mirrors smbcloud_model::account::ErrorCode (distinct from SmbCloudErrorCode). |
| 108 | public enum AccountErrorCode: Int, Sendable { |
| 109 | case emailNotFound = 1000, emailUnverified = 1001, emailConfirmationFailed = 1002, |
| 110 | passwordNotSet = 1003, githubNotLinked = 1004, emailAlreadyExist = 1005, |
| 111 | invalidPassword = 1006, hostedMailAccountUnverified = 1007 |
| 112 | } |
| 113 | |
| 114 | /// me.rs returns smbcloud_model::account::User (the SDK decodes only these). |
| 115 | public struct User: Codable, Sendable { |
| 116 | public let id: Int |
| 117 | public let email: String |
| 118 | public let createdAt: Date |
| 119 | public let updatedAt: Date |
| 120 | } |
| 121 | |
| 122 | /// signup.rs returns smbcloud_model::signup::SignupResult. |
| 123 | public struct SignupResult: Codable, Sendable { |
| 124 | public let code: Int? |
| 125 | public let message: String |
| 126 | public let data: AccountData? |
| 127 | } |
| 128 | |
| 129 | public struct AccountData: Codable, Sendable { // account::Data |
| 130 | public let id: Int |
| 131 | public let email: String |
| 132 | public let createdAt: String |
| 133 | } |
| 134 | ``` |
| 135 | |
| 136 | ### `login` → `AccountStatus` mapping (from `network::request_login`) |
| 137 | |
| 138 | | HTTP | Result | |
| 139 | |-----------------------------------|--------| |
| 140 | | `200` + `Authorization` header | `.ready(accessToken: <header value>)` | |
| 141 | | `200`, no header, body `EmailNotVerified` | `.incomplete(.emailUnverified)` | |
| 142 | | `200`, no header, body `PasswordNotSet` | `.incomplete(.passwordNotSet)` | |
| 143 | | `404` | `.notFound` | |
| 144 | | `422` | parse `SmbAuthorization` → `.incomplete(error_code)`, else throw `SmbCloudError(.networkError, message)` | |
| 145 | | transport failure | throw `SmbCloudError(.networkError, …)` | |
| 146 | |
| 147 | ## Apple provider flow (tenant) |
| 148 | |
| 149 | ```swift |
| 150 | extension AuthCoreClient { |
| 151 | // apple.rs → builds v1/client/oauth/apple/authorize URL (creds in query) |
| 152 | public func buildAppleAuthorizationRequest( |
| 153 | redirectURI: String, state: String? = nil |
| 154 | ) throws -> AppleAuthorizationRequest |
| 155 | |
| 156 | public func parseAppleCallbackURL( |
| 157 | _ callbackURL: String, expectedState: String? = nil |
| 158 | ) throws -> AppleAuthSession |
| 159 | } |
| 160 | |
| 161 | public struct AppleAuthorizationRequest: Sendable { |
| 162 | public let authorizeURL: String |
| 163 | public let redirectURI: String |
| 164 | public let state: String |
| 165 | } |
| 166 | |
| 167 | public struct AppleAuthSession: Sendable { |
| 168 | public let accessToken: String |
| 169 | public let refreshToken: String? |
| 170 | public let email: String? |
| 171 | public let name: String? |
| 172 | public let provider: String // defaults "apple" |
| 173 | public let providerAccountId: String |
| 174 | public let state: String? |
| 175 | } |
| 176 | ``` |
| 177 | |
| 178 | ## OIDC public-client flow (`oidc.rs`) |
| 179 | |
| 180 | Public client: uses `oidcClientId` only — **no `app_secret`** — against the |
| 181 | issuer endpoints `oauth/authorize|token|userinfo`. PKCE: `code_verifier` = two |
| 182 | concatenated UUID-simple strings; `code_challenge` = base64url-no-pad(SHA256); |
| 183 | `code_challenge_method=S256`; `scope="openid profile email"`. |
| 184 | |
| 185 | ```swift |
| 186 | public enum OIDC { |
| 187 | public static func buildAuthorizationRequest( |
| 188 | environment: SmbCloudEnvironment, oidcClientId: String, redirectURI: String |
| 189 | ) throws -> AuthorizationRequest |
| 190 | |
| 191 | public static func parseCallbackURL(_ callbackURL: String) throws -> CallbackPayload |
| 192 | |
| 193 | public static func exchangeCode( |
| 194 | environment: SmbCloudEnvironment, oidcClientId: String, |
| 195 | redirectURI: String, code: String, codeVerifier: String |
| 196 | ) async throws -> TokenResponse |
| 197 | |
| 198 | public static func getUserInfo( |
| 199 | environment: SmbCloudEnvironment, accessToken: String, tenantId: String? = nil |
| 200 | ) async throws -> UserInfo |
| 201 | } |
| 202 | |
| 203 | public struct AuthorizationRequest: Sendable { |
| 204 | public let authorizeURL: String |
| 205 | public let redirectURI: String |
| 206 | public let state: String |
| 207 | public let codeVerifier: String |
| 208 | } |
| 209 | public struct CallbackPayload: Sendable { public let code: String; public let state: String } |
| 210 | public struct TokenResponse: Codable, Sendable { |
| 211 | public let accessToken: String |
| 212 | public let tokenType: String |
| 213 | public let expiresIn: Int? |
| 214 | public let refreshToken: String? |
| 215 | public let scope: String? |
| 216 | public let idToken: String? |
| 217 | } |
| 218 | public struct UserInfo: Codable, Sendable { |
| 219 | public let sub: String |
| 220 | public let email: String? |
| 221 | public let emailVerified: Bool? |
| 222 | public let tenantId: UInt64? |
| 223 | public let tenantSlug: String? |
| 224 | } |
| 225 | ``` |
| 226 | |
| 227 | ## Transport notes (match the Rust crate exactly) |
| 228 | |
| 229 | - **Credentials are QUERY params** named `client_id` / `client_secret` on every |
| 230 | `/v1/client/*` URL (built by `base_url_builder`), even for POST/DELETE. |
| 231 | - `login` / `signup` send header `User-agent: <app_id>` and a JSON body. |
| 232 | - `me` sends `Authorization: <accessToken>` (the value is passed **as-is** — it |
| 233 | already includes the `Bearer ` prefix because `.ready` captured the full |
| 234 | header), plus `Accept: application/json` and |
| 235 | `Content-Type: application/x-www-form-urlencoded`. |
| 236 | - `logout` is bespoke: `200` → success, anything else → `SmbCloudError(.unauthorized)`. |
| 237 | - `remove` decodes an empty body (`request::<()>`). |
| 238 | - Use the host HTTP stack (`URLSession` on Apple, `URLSession`/async-http-client |
| 239 | on Linux). No embedded runtime. |
| 240 | |
| 241 | ## Layering & client confidentiality |
| 242 | |
| 243 | - `AuthCore` (this surface) is headless and builds on Linux + Apple. The Vapor |
| 244 | `api-server` depends on it directly and is the **only** place that holds |
| 245 | `app_secret` (confidential client). |
| 246 | - `SmbCloudAuth` (Apple-only) keeps the UI/Keychain layer (ASWebAuthenticationSession |
| 247 | for the OIDC/Apple flows) and re-exports `AuthCore`. |
| 248 | - **Never ship `app_secret` in a distributed client** (native app / browser). |
| 249 | Public clients use the OIDC/Apple public flows or route through a BFF |
| 250 | (SwiftStore's `api-server`). |
| 251 | |
| 252 | ## Conformance |
| 253 | |
| 254 | Pin AuthCore to the crate with shared contract tests: golden request fixtures |
| 255 | (method, path, query, headers, body) and response→type fixtures covering the |
| 256 | `AccountStatus` mapping and the error model, run in CI exactly as the other |
| 257 | bindings are. Drift fails the build. |