5 files changed
+262
-5
.agents/skills/smbcloud-auth/SKILL.md
+100
-5
@@ -1,6 +1,6 @@
1
---
2
name: smbcloud-auth
3
-description: Use when building, debugging, or extending smbCloud authentication across the Rails auth service, the web console, the Rust networking SDK, and Tauri client apps. Covers platform clients vs tenant auth apps, AuthUser signup/sign-in/confirmation, shared auth apps across projects, and development vs production app credentials.
3
+description: Use when building, debugging, or extending smbCloud authentication across the Rails auth service, the web console, the Rust or WASM auth SDKs, and Tauri or web client apps. Covers platform clients vs tenant auth apps, AuthUser signup/sign-in/confirmation, OAuth providers for AuthUser, shared auth apps across projects, browser SDK integration, and development vs production app credentials.
4
---
5
6
# smbCloud Auth
@@ -10,7 +10,9 @@ Use this skill when work touches any part of the smbCloud auth stack:
10
- `smbcloud-api` Rails auth service
11
- `smbcloud-web-console` Next.js admin UI
12
- `smbcloud-cli/crates/smbcloud-auth` Rust SDK
13
+- `smbcloud-cli/crates/smbcloud-auth-wasm` browser SDK
14
- Tauri apps such as Rumi Learn Persian and PBJ Komplit
15
+- web apps that use `@smbcloud/sdk-auth`
16
17
## Core model
18
@@ -54,6 +56,22 @@ The tenant auth domain is:
56
57
Tenant apps must use `AuthAppClientAuthenticatable` and must not go through platform `/v1/users`.
58
59
+### Provider auth boundary
60
+
61
+OAuth providers such as Apple or Google for customer apps belong to the tenant auth plane:
62
+
63
+- provider sign-in for end users must resolve to `AuthUser`
64
+- provider linkage must be scoped to `AuthApp`
65
+- the result must be the same tenant auth session shape used by email/password
66
+
67
+Do not route tenant provider auth through:
68
+
69
+- platform `User`
70
+- `Authorization` records for platform users
71
+- Doorkeeper/OIDC smbCloud internal users
72
+
73
+If provider support is added for tenant apps, the backend contract should end in an `AuthUser` session or JWT, not a provider token as the primary app session.
74
+
75
## Multi-project auth apps
76
77
Current rule:
@@ -73,6 +91,31 @@ When editing `smbcloud-api`:
91
- Do not reuse `OauthApplication` for tenant end-user login
92
- Do not hardcode tenant app validation in platform controllers
93
94
+### Tenant provider auth modeling
95
+
96
+If tenant users need Apple, Google, or other OAuth providers:
97
+
98
+- add provider linkage for `AuthUser`, not platform `User`
99
+- model by stable provider subject, not by email
100
+- keep provider records scoped to the tenant auth app
101
+
102
+Preferred shape:
103
+
104
+- `AuthUserAuthorization`
105
+ - `auth_user_id`
106
+ - `provider`
107
+ - `uid`
108
+ - optional `email`
109
+ - optional `raw_profile`
110
+
111
+For Apple specifically:
112
+
113
+- use Apple `sub` as the stable provider identity
114
+- do not treat email as the primary key
115
+- do not assume name is available after first sign-in
116
+
117
+The Rails backend should verify provider assertions and then mint the normal tenant auth session for the resolved `AuthUser`.
118
+
119
### AuthUser signup/sign-in rules
120
121
- Signup creates an `AuthUser` scoped to `current_auth_app`
@@ -95,6 +138,7 @@ Do not claim email confirmation exists unless the mailer and route are actually
138
The Rust client surface lives in:
139
140
- `smbcloud-cli/crates/smbcloud-auth`
141
+- `smbcloud-cli/crates/smbcloud-auth-wasm`
142
143
When changing tenant app auth:
144
@@ -107,6 +151,33 @@ When changing tenant app auth:
151
152
If the backend changes error shape or status codes, update the Rust parser accordingly.
153
154
+### Browser/WASM SDK conventions
155
+
156
+The browser package is consumed as `@smbcloud/sdk-auth`.
157
+
158
+Current exported surface includes:
159
+
160
+- `signup_with_client`
161
+- `login_with_client`
162
+- `me_with_client`
163
+- `logout_with_client`
164
+- `remove_with_client`
165
+- `Environment.Dev`
166
+- `Environment.Production`
167
+
168
+Recommended browser integration pattern:
169
+
170
+- lazy-load the WASM runtime with `await import("@smbcloud/sdk-auth")`
171
+- call `await runtime.default()` before using the exported functions
172
+- store only the access token in browser storage
173
+- restore the session with `me_with_client`
174
+- gate environment switching behind explicit debug controls
175
+
176
+Do not build browser flows around NextAuth sessions if the product session is actually an smbCloud `AuthUser` token. The browser app should either:
177
+
178
+- use the browser SDK directly for email/password flows, or
179
+- complete provider auth through Rails and then store the resulting tenant auth token
180
+
181
## Tauri client conventions
182
183
For Rumi/PBJ-style apps:
@@ -143,6 +214,18 @@ Recommended env variable names:
214
- `SMBCLOUD_APP_ID_PRODUCTION`
215
- `SMBCLOUD_APP_SECRET_PRODUCTION`
216
217
+### Public-client security rule
218
+
219
+Tauri, mobile, and browser apps are public clients. Treat `app_id` as public and `app_secret` as non-confidential if it is shipped in a client binary or browser bundle.
220
+
221
+Implications:
222
+
223
+- do not claim the client app secret is secure once distributed
224
+- do not rely on embedded `app_secret` as the main client authentication boundary
225
+- prefer backend-mediated flows, PKCE, or other public-client-safe patterns for stronger security
226
+
227
+If the current SDK or API still requires `app_secret` from public clients, call out that limitation explicitly in code review or implementation notes.
228
+
229
### macOS Tauri dev builds
230
231
`tauri.conf.json` `minimumSystemVersion` is not enough for `tauri dev`.
@@ -176,10 +259,11 @@ Expected behavior:
259
260
1. Decide whether the change is platform auth or tenant auth.
261
2. Update Rails first.
179
-3. Update Rust SDK endpoints/parsing if the API contract changed.
180
-4. Update Tauri clients to match.
181
-5. Update the web console if auth app management changed.
182
-6. Validate end-to-end.
262
+3. If OAuth providers are involved, confirm whether the provider is for `AuthUser` or internal `User`. Default tenant/customer providers to `AuthUser`.
263
+4. Update Rust or WASM SDK endpoints/parsing if the API contract changed.
264
+5. Update Tauri or browser clients to match.
265
+6. Update the web console if auth app management changed.
266
+7. Validate end-to-end.
267
268
## Validation
269
@@ -194,6 +278,7 @@ Use the smallest relevant checks.
278
### Rust SDK
279
280
- `cargo check -p smbcloud-auth`
281
+- `cargo check -p smbcloud-auth-wasm`
282
283
### Tauri apps
284
@@ -204,10 +289,20 @@ Use the smallest relevant checks.
289
290
- `pnpm build`
291
292
+### Browser SDK apps
293
+
294
+- `pnpm build`
295
+- verify login, logout, and session restore in the browser
296
+- if environment override exists, verify both development and production modes
297
+
298
## Common mistakes
299
300
- Sending tenant app credentials to `/v1/users`
301
- Using `OauthApplication` instead of `AuthApp` for tenant login
302
- Replacing platform client validation with tenant auth app validation
303
+- Implementing Apple or Google for tenant users on platform `User` instead of `AuthUser`
304
+- Returning raw provider tokens as the primary tenant app session
305
+- Treating a browser or native app `app_secret` as confidential
306
- Auto-confirming `AuthUser` while telling the client to check email
307
+- Building smbCloud `AuthUser` web auth on top of unrelated NextAuth session state
308
- Forgetting that `tauri dev` ignores `tauri.conf.json` macOS minimum version
crates/smbcloud-auth-wasm/src/apple.rs
new
+42
@@ -0,0 +1,42 @@
1
+use smbcloud_auth::client_credentials::ClientCredentials;
2
+use smbcloud_network::environment::Environment;
3
+use wasm_bindgen::JsValue;
4
+use wasm_bindgen::prelude::wasm_bindgen;
5
+
6
+#[wasm_bindgen]
7
+pub fn build_apple_authorization_request_with_client(
8
+ env: Environment,
9
+ app_id: String,
10
+ app_secret: String,
11
+ redirect_uri: String,
12
+ state: Option<String>,
13
+) -> Result<JsValue, JsValue> {
14
+ let client = ClientCredentials {
15
+ app_id: &app_id,
16
+ app_secret: &app_secret,
17
+ };
18
+
19
+ match smbcloud_auth::apple::build_authorization_request_with_client(
20
+ env,
21
+ client,
22
+ redirect_uri,
23
+ state,
24
+ ) {
25
+ Ok(response) => Ok(serde_wasm_bindgen::to_value(&response)?),
26
+ Err(error) => Err(serde_wasm_bindgen::to_value(&error)?),
27
+ }
28
+}
29
+
30
+#[wasm_bindgen]
31
+pub fn parse_apple_callback_url(
32
+ callback_url: String,
33
+ expected_state: Option<String>,
34
+) -> Result<JsValue, JsValue> {
35
+ match smbcloud_auth::apple::parse_callback_url(
36
+ &callback_url,
37
+ expected_state.as_deref(),
38
+ ) {
39
+ Ok(response) => Ok(serde_wasm_bindgen::to_value(&response)?),
40
+ Err(error) => Err(serde_wasm_bindgen::to_value(&error)?),
41
+ }
42
+}
crates/smbcloud-auth-wasm/src/lib.rs
+1
@@ -1,3 +1,4 @@
1
+mod apple;
2
mod login;
3
mod logout;
4
mod me;
crates/smbcloud-auth/src/apple.rs
new
+118
@@ -0,0 +1,118 @@
1
+use {
2
+ crate::client_credentials::{ClientCredentials, base_url_builder as tenant_base_url_builder},
3
+ reqwest::Url,
4
+ serde::{Deserialize, Serialize},
5
+ smbcloud_model::error_codes::{ErrorCode, ErrorResponse},
6
+ smbcloud_network::environment::Environment,
7
+ uuid::Uuid,
8
+};
9
+
10
+const TENANT_APPLE_AUTHORIZE_PATH: &str = "v1/client/oauth/apple/authorize";
11
+
12
+#[derive(Debug, Clone, Serialize, Deserialize)]
13
+pub struct AppleAuthorizationRequest {
14
+ pub authorize_url: String,
15
+ pub redirect_uri: String,
16
+ pub state: String,
17
+}
18
+
19
+#[derive(Debug, Clone, Serialize, Deserialize)]
20
+pub struct AppleAuthSession {
21
+ pub access_token: String,
22
+ pub refresh_token: Option<String>,
23
+ pub email: Option<String>,
24
+ pub name: Option<String>,
25
+ pub provider: String,
26
+ pub provider_account_id: String,
27
+ pub state: Option<String>,
28
+}
29
+
30
+pub fn build_authorization_request_with_client(
31
+ env: Environment,
32
+ client: ClientCredentials<'_>,
33
+ redirect_uri: String,
34
+ state: Option<String>,
35
+) -> Result<AppleAuthorizationRequest, ErrorResponse> {
36
+ let state = state.unwrap_or_else(|| Uuid::new_v4().to_string());
37
+ let mut url_builder = tenant_base_url_builder(env, client);
38
+
39
+ url_builder
40
+ .add_route(TENANT_APPLE_AUTHORIZE_PATH)
41
+ .add_param("redirect_uri", &redirect_uri)
42
+ .add_param("state", &state);
43
+
44
+ Ok(AppleAuthorizationRequest {
45
+ authorize_url: url_builder.build(),
46
+ redirect_uri,
47
+ state,
48
+ })
49
+}
50
+
51
+pub fn parse_callback_url(
52
+ callback_url: &str,
53
+ expected_state: Option<&str>,
54
+) -> Result<AppleAuthSession, ErrorResponse> {
55
+ let url = Url::parse(callback_url).map_err(|err| ErrorResponse::Error {
56
+ error_code: ErrorCode::ParseError,
57
+ message: err.to_string(),
58
+ })?;
59
+
60
+ let mut access_token = None;
61
+ let mut refresh_token = None;
62
+ let mut email = None;
63
+ let mut name = None;
64
+ let mut provider = None;
65
+ let mut provider_account_id = None;
66
+ let mut state = None;
67
+ let mut error = None;
68
+
69
+ for (key, value) in url.query_pairs() {
70
+ match key.as_ref() {
71
+ "access_token" => access_token = Some(value.into_owned()),
72
+ "refresh_token" => refresh_token = Some(value.into_owned()),
73
+ "email" => email = Some(value.into_owned()),
74
+ "name" => name = Some(value.into_owned()),
75
+ "provider" => provider = Some(value.into_owned()),
76
+ "provider_account_id" => provider_account_id = Some(value.into_owned()),
77
+ "state" => state = Some(value.into_owned()),
78
+ "error" => error = Some(value.into_owned()),
79
+ _ => {}
80
+ }
81
+ }
82
+
83
+ if let Some(error) = error {
84
+ return Err(ErrorResponse::Error {
85
+ error_code: ErrorCode::InvalidParams,
86
+ message: error,
87
+ });
88
+ }
89
+
90
+ if let Some(expected_state) = expected_state {
91
+ if state.as_deref() != Some(expected_state) {
92
+ return Err(ErrorResponse::Error {
93
+ error_code: ErrorCode::InvalidParams,
94
+ message: "Apple callback state mismatch.".to_string(),
95
+ });
96
+ }
97
+ }
98
+
99
+ Ok(AppleAuthSession {
100
+ access_token: required_param("access_token", access_token)?,
101
+ refresh_token,
102
+ email,
103
+ name,
104
+ provider: provider.unwrap_or_else(|| "apple".to_string()),
105
+ provider_account_id: required_param("provider_account_id", provider_account_id)?,
106
+ state,
107
+ })
108
+}
109
+
110
+fn required_param(
111
+ name: &str,
112
+ value: Option<String>,
113
+) -> Result<String, ErrorResponse> {
114
+ value.ok_or_else(|| ErrorResponse::Error {
115
+ error_code: ErrorCode::InvalidParams,
116
+ message: format!("Missing `{name}` in Apple callback URL."),
117
+ })
118
+}
crates/smbcloud-auth/src/lib.rs
+1
@@ -1,3 +1,4 @@
1
+pub mod apple;
2
pub mod check_email;
3
pub mod client_credentials;
4
pub mod login;