main
md 257 lines 10.5 KB
Rendered Raw
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.