feat(auth): hosted Authorization Code + PKCE client flow
Move the OIDC client SDK onto the dedicated client endpoints (v1/client/oauth/authorize|token|userinfo) and align the model layer with the hosted PKCE flow: - UserInfo: scope-gated claims (name, preferred_username, role, updated_at, email, email_verified); drop tenant_id/tenant_slug. - oauth::TokenResponse: add optional id_token (present when the `openid` scope is granted). - app_auth: add AuthAppClient / AuthAppClientCreate (public OAuth clients — no secret, PKCE + redirect_uris allowlist), tsync-exported. - Remove legacy smbcloud-auth code paths (apple, client_credentials, old oidc) superseded by the PKCE flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
keypair34 committed
Jun 27, 2026 at 20:26 UTC
98a9170354de7e5d9a28152a27b30909ec6c6c8b
7 files changed
+124
-303
.agents/AGENTS.md
+68
@@ -43,3 +43,71 @@ Keep docs and skills **generic** — describe how the tool behaves, using placeh
43
}
44
});
45
```
46
+
47
+---
48
+
49
+# Build, test, and run
50
+
51
+This is a Cargo workspace pinned to stable Rust (`rust-toolchain.toml`). The CLI binary is named **`smb`** (crate `smbcloud-cli`, in `crates/cli`).
52
+
53
+```sh
54
+cargo build # build everything (debug)
55
+cargo run -p smbcloud-cli -- <args> # run the CLI, e.g. -- me, -- deploy, -- --help
56
+cargo run -p smbcloud-cli -- -e dev me # talk to a local API (Environment::Dev -> http://localhost:8088)
57
+```
58
+
59
+CI gate (mirror it locally before pushing — see `.github/workflows/ci.yml`):
60
+
61
+```sh
62
+cargo fmt --all -- --check
63
+cargo clippy --workspace --exclude smbcloud-auth-sdk-wasm --tests -- -D warnings # warnings are denied
64
+cargo test --workspace --exclude smbcloud-auth-sdk-wasm
65
+cargo test -p smbcloud-model app_auth::tests::some_test # a single test
66
+```
67
+
68
+`smbcloud-auth-sdk-wasm` targets `wasm32-unknown-unknown` and cannot build or test on the host — it is always excluded from workspace clippy/test and checked separately: `cargo check -p smbcloud-auth-sdk-wasm --target wasm32-unknown-unknown`.
69
+
70
+`CLI_CLIENT_SECRET` (see `.env.example`) is the OAuth client-credentials secret read at runtime; tests and `cargo check` don't need it, but login flows against a real API do.
71
+
72
+## Releases
73
+
74
+Releases are prepared on the `development` branch with a clean tree via the Makefile, which drives `cargo workspaces` to bump every crate in lockstep, then syncs the package-manager manifests (npm/nuget/pypi) and regenerates the SDK lockfiles:
75
+
76
+```sh
77
+make patch | make minor | make major | make custom VERSION=0.x.y
78
+```
79
+
80
+It commits `Release <version>` and tags `v<version>` locally; pushing is manual. Crate publishing is separate and manual (`cargo workspaces publish`, see `docs/development.md`). The per-target release workflows (`release-*.yml`) build the distributable artifacts.
81
+
82
+---
83
+
84
+# Architecture
85
+
86
+`smb` is a thin async (`tokio`) `clap` front-end over a set of `smbcloud-*` library crates that talk to the smbCloud REST API. The product purpose is **deploying apps** (Node.js/Next.js, Ruby/Rails, Rust, Swift/Vapor, static/Vite) from the terminal.
87
+
88
+## Request flow
89
+
90
+`crates/cli/src/main.rs` → `cli/mod.rs` (the `Cli`/`Commands` clap tree) → a `process_*` handler under `account/`, `project/`, `deploy/`, or `mail/`. Each handler returns a `CommandResult` (a spinner + final symbol/message); `main` prints it and sets the exit code. The top-level `--environment` (`dev`/`production`, default production) threads through every handler and selects the API host and the on-disk state dir (`.smb` vs `.smb-dev`, see `smbcloud-network/src/environment.rs`).
91
+
92
+Two cross-cutting globals are resolved once in `main` before any handler runs:
93
+- **CI mode** (`crates/cli/src/ci.rs`): `--ci` / `SMB_CI=1` / conventional `CI` env. Stored in a process-global `AtomicBool` (`ci::is_ci()`) rather than threaded through signatures, so deep prompt code can fail fast instead of blocking on a missing TTY. New interactive prompts must check `is_ci()` and use `ci::interactive_message(...)`.
94
+- **Logging**: bunyan JSON to `~/<smb_dir>/smbcloud-cli.log` (not stdout); user-facing output is `console`/spinner styling.
95
+
96
+## Crate layout (workspace)
97
+
98
+- `smbcloud-model` — shared serde types (`Project`, `User`, `Runner`, deploy config, `ErrorCode`/`ErrorResponse`). `crate-type = ["cdylib","rlib"]` because the SDK crates re-export it. Most unit tests live here.
99
+- `smbcloud-network` — `Environment` enum (host/protocol/state-dir resolution) and connectivity checks.
100
+- `smbcloud-networking` — `SmbClient` (the OAuth client identity) and the low-level HTTP client/constants.
101
+- `smbcloud-networking-project` — typed REST calls for projects, deploy repos, frontend apps, deploy config, and deployments (the `crud_*` files).
102
+- `smbcloud-auth` / `smbcloud-auth-sdk` — auth flows (login, signup, OIDC, Apple, client-credentials, reset/verify). `auth` is the in-CLI implementation; `auth-sdk` is the embeddable client.
103
+- `smbcloud-auth-sdk-wasm` / `-py` (+ `sdk/`) — language bindings: WASM/`wasm-bindgen` for npm, PyO3 `cdylib` for Python, and a separate `rb_sys` Cargo workspace under `sdk/gems` for the Ruby gem. These wrap `smbcloud-auth-sdk`.
104
+- `smbcloud-mail`, `smbcloud-gresiq`(`-sdk`), `smbcloud-s6n` — clients for the Mail, GresIQ (serverless Postgres), and S6n (S3-compatible storage) products.
105
+- `smbcloud-utils` — `.smb/config.toml` (`Config`) parsing and SSH key-path resolution.
106
+
107
+## Deploy subsystem (`crates/cli/src/deploy/`)
108
+
109
+The most involved area. `process_deploy.rs` loads `.smb/config.toml` (running interactive `setup_project` if missing), overlays server-side config, validates project access, then **dispatches by `config.project.kind`** to a per-stack path: `vite-spa`, `nextjs-ssr`, `rails`, `rust`, `swift` each have their own `process_deploy_*.rs`. If `kind` is unset it falls back to `deployment_method`: `Rsync` (build locally, upload over rsync, restart over SSH) or `Git` (push to a remote git hook), with `detect_runner.rs` sniffing the framework from `package.json`/`Gemfile`/`Package.swift`/`Cargo.toml`.
110
+
111
+**Monorepo**: `runner = Monorepo` with a `[[projects]]` array in config; `smb deploy --project <name>` (or an interactive picker) swaps `config.project` to the named sub-project before the dispatch above. `migrate.rs`/`smb migrate` pushes local deploy fields up to the server.
112
+
113
+When editing a deploy path, keep the public-repo policy above in mind: SSH key names, hosts, ports, app/tenant identifiers, and PM2 process names must stay as placeholders — concrete fleet detail belongs only in the private `smbcloud` repo.
crates/smbcloud-auth-sdk/src/oidc.rs
+19
-5
@@ -8,9 +8,9 @@ use {
8
uuid::Uuid,
9
};
10
11
-const AUTHORIZE_PATH: &str = "oauth/authorize";
12
-const TOKEN_PATH: &str = "oauth/token";
13
-const USERINFO_PATH: &str = "oauth/userinfo";
11
+const AUTHORIZE_PATH: &str = "v1/client/oauth/authorize";
12
+const TOKEN_PATH: &str = "v1/client/oauth/token";
13
+const USERINFO_PATH: &str = "v1/client/oauth/userinfo";
14
15
#[derive(Debug, Clone, Serialize, Deserialize)]
16
pub struct AuthorizationRequest {
@@ -36,13 +36,27 @@ pub struct TokenResponse {
36
pub id_token: Option<String>,
37
}
38
39
+/// OIDC userinfo claims returned by `GET /v1/client/oauth/userinfo`.
40
+///
41
+/// Only `sub` is always present; every other claim is gated by the scopes the
42
+/// access token carries (`profile` → name/preferred_username/role/updated_at,
43
+/// `email` → email/email_verified), so each is optional. `role` is the AuthUser
44
+/// role as its integer enum value; `updated_at` is a Unix timestamp (seconds).
45
#[derive(Debug, Clone, Serialize, Deserialize)]
46
pub struct UserInfo {
47
pub sub: String,
48
+ #[serde(default, skip_serializing_if = "Option::is_none")]
49
+ pub name: Option<String>,
50
+ #[serde(default, skip_serializing_if = "Option::is_none")]
51
+ pub preferred_username: Option<String>,
52
+ #[serde(default, skip_serializing_if = "Option::is_none")]
53
+ pub role: Option<i32>,
54
+ #[serde(default, skip_serializing_if = "Option::is_none")]
55
+ pub updated_at: Option<i64>,
56
+ #[serde(default, skip_serializing_if = "Option::is_none")]
57
pub email: Option<String>,
58
+ #[serde(default, skip_serializing_if = "Option::is_none")]
59
pub email_verified: Option<bool>,
44
- pub tenant_id: Option<u64>,
45
- pub tenant_slug: Option<String>,
60
}
61
62
pub fn build_authorization_request(
crates/smbcloud-auth/src/apple.rs
deleted
-118
@@ -1,118 +0,0 @@
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/client_credentials.rs
deleted
-17
@@ -1,17 +0,0 @@
1
-use {smbcloud_network::environment::Environment, url_builder::URLBuilder};
2
-
3
-#[derive(Clone, Copy)]
4
-pub struct ClientCredentials<'a> {
5
- pub app_id: &'a str,
6
- pub app_secret: &'a str,
7
-}
8
-
9
-pub fn base_url_builder(env: Environment, client: ClientCredentials<'_>) -> URLBuilder {
10
- let mut url_builder = URLBuilder::new();
11
- url_builder
12
- .set_protocol(&env.api_protocol())
13
- .set_host(&env.api_host())
14
- .add_param("client_id", client.app_id)
15
- .add_param("client_secret", client.app_secret);
16
- url_builder
17
-}
crates/smbcloud-auth/src/oidc.rs
deleted
-163
@@ -1,163 +0,0 @@
1
-use {
2
- base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD},
3
- reqwest::{Client, Url},
4
- serde::{Deserialize, Serialize},
5
- sha2::{Digest, Sha256},
6
- smbcloud_model::error_codes::{ErrorCode, ErrorResponse},
7
- smbcloud_network::{environment::Environment, network},
8
- uuid::Uuid,
9
-};
10
-
11
-const AUTHORIZE_PATH: &str = "oauth/authorize";
12
-const TOKEN_PATH: &str = "oauth/token";
13
-const USERINFO_PATH: &str = "oauth/userinfo";
14
-
15
-#[derive(Debug, Clone, Serialize, Deserialize)]
16
-pub struct AuthorizationRequest {
17
- pub authorize_url: String,
18
- pub redirect_uri: String,
19
- pub state: String,
20
- pub code_verifier: String,
21
-}
22
-
23
-#[derive(Debug, Clone, Serialize, Deserialize)]
24
-pub struct CallbackPayload {
25
- pub code: String,
26
- pub state: String,
27
-}
28
-
29
-#[derive(Debug, Clone, Serialize, Deserialize)]
30
-pub struct TokenResponse {
31
- pub access_token: String,
32
- pub token_type: String,
33
- pub expires_in: Option<i32>,
34
- pub refresh_token: Option<String>,
35
- pub scope: Option<String>,
36
- pub id_token: Option<String>,
37
-}
38
-
39
-#[derive(Debug, Clone, Serialize, Deserialize)]
40
-pub struct UserInfo {
41
- pub sub: String,
42
- pub email: Option<String>,
43
- pub email_verified: Option<bool>,
44
- pub tenant_id: Option<u64>,
45
- pub tenant_slug: Option<String>,
46
-}
47
-
48
-pub fn build_authorization_request(
49
- env: Environment,
50
- oidc_client_id: &str,
51
- redirect_uri: String,
52
-) -> Result<AuthorizationRequest, ErrorResponse> {
53
- let code_verifier = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
54
- let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
55
- let state = Uuid::new_v4().to_string();
56
-
57
- let mut url =
58
- issuer_base_url(env)
59
- .join(AUTHORIZE_PATH)
60
- .map_err(|err| ErrorResponse::Error {
61
- error_code: ErrorCode::ParseError,
62
- message: err.to_string(),
63
- })?;
64
-
65
- url.query_pairs_mut()
66
- .append_pair("client_id", oidc_client_id)
67
- .append_pair("redirect_uri", &redirect_uri)
68
- .append_pair("response_type", "code")
69
- .append_pair("scope", "openid profile email")
70
- .append_pair("state", &state)
71
- .append_pair("code_challenge", &code_challenge)
72
- .append_pair("code_challenge_method", "S256");
73
-
74
- Ok(AuthorizationRequest {
75
- authorize_url: url.to_string(),
76
- redirect_uri,
77
- state,
78
- code_verifier,
79
- })
80
-}
81
-
82
-pub fn parse_callback_url(callback_url: &str) -> Result<CallbackPayload, ErrorResponse> {
83
- let url = Url::parse(callback_url).map_err(|err| ErrorResponse::Error {
84
- error_code: ErrorCode::ParseError,
85
- message: err.to_string(),
86
- })?;
87
-
88
- let mut code = None;
89
- let mut state = None;
90
-
91
- for (key, value) in url.query_pairs() {
92
- match key.as_ref() {
93
- "code" => code = Some(value.into_owned()),
94
- "state" => state = Some(value.into_owned()),
95
- _ => {}
96
- }
97
- }
98
-
99
- match (code, state) {
100
- (Some(code), Some(state)) => Ok(CallbackPayload { code, state }),
101
- _ => Err(ErrorResponse::Error {
102
- error_code: ErrorCode::InvalidParams,
103
- message: "Missing authorization code or state.".to_string(),
104
- }),
105
- }
106
-}
107
-
108
-pub async fn exchange_code(
109
- env: Environment,
110
- oidc_client_id: &str,
111
- redirect_uri: &str,
112
- code: &str,
113
- code_verifier: &str,
114
-) -> Result<TokenResponse, ErrorResponse> {
115
- let url = issuer_base_url(env)
116
- .join(TOKEN_PATH)
117
- .map_err(|err| ErrorResponse::Error {
118
- error_code: ErrorCode::ParseError,
119
- message: err.to_string(),
120
- })?;
121
-
122
- let builder = Client::new()
123
- .post(url)
124
- .form(&[
125
- ("grant_type", "authorization_code"),
126
- ("client_id", oidc_client_id),
127
- ("code", code),
128
- ("redirect_uri", redirect_uri),
129
- ("code_verifier", code_verifier),
130
- ])
131
- .header("Accept", "application/json");
132
-
133
- network::request(builder).await
134
-}
135
-
136
-pub async fn get_userinfo(
137
- env: Environment,
138
- access_token: &str,
139
- tenant_id: Option<&str>,
140
-) -> Result<UserInfo, ErrorResponse> {
141
- let url = issuer_base_url(env)
142
- .join(USERINFO_PATH)
143
- .map_err(|err| ErrorResponse::Error {
144
- error_code: ErrorCode::ParseError,
145
- message: err.to_string(),
146
- })?;
147
-
148
- let mut builder = Client::new()
149
- .get(url)
150
- .bearer_auth(access_token)
151
- .header("Accept", "application/json");
152
-
153
- if let Some(tenant_id) = tenant_id {
154
- builder = builder.header("X-Smbcloud-Tenant-Id", tenant_id);
155
- }
156
-
157
- network::request(builder).await
158
-}
159
-
160
-fn issuer_base_url(env: Environment) -> Url {
161
- Url::parse(&format!("{}://{}/", env.api_protocol(), env.api_host()))
162
- .expect("valid smbcloud api base url")
163
-}
crates/smbcloud-model/src/app_auth.rs
+33
@@ -1,6 +1,7 @@
1
use crate::ar_date_format;
2
use chrono::{DateTime, Utc};
3
use serde::{Deserialize, Serialize};
4
+use tsync::tsync;
5
6
#[derive(Serialize, Deserialize, Debug, Clone)]
7
pub struct AuthApp {
@@ -19,6 +20,38 @@ pub struct AuthAppCreate {
20
pub description: String,
21
}
22
23
+/// A public OAuth client registered against an AuthApp for the hosted
24
+/// Authorization Code + PKCE flow.
25
+///
26
+/// These are public clients: no client secret is issued, so security rests on
27
+/// PKCE plus the `redirect_uris` allowlist. `redirect_uris` is a newline-
28
+/// separated list. `client_id` is the public identifier (prefixed `auc_`) sent
29
+/// in the `/authorize` request.
30
+#[derive(Serialize, Deserialize, Debug, Clone)]
31
+#[tsync]
32
+pub struct AuthAppClient {
33
+ pub id: i64,
34
+ pub client_id: String,
35
+ pub name: String,
36
+ pub redirect_uris: String,
37
+ pub confidential: bool,
38
+ pub auth_app_id: String,
39
+ #[serde(with = "ar_date_format")]
40
+ pub created_at: DateTime<Utc>,
41
+ #[serde(with = "ar_date_format")]
42
+ pub updated_at: DateTime<Utc>,
43
+}
44
+
45
+/// Request body for registering a new public OAuth client on an AuthApp.
46
+/// `redirect_uris` is a newline-separated allowlist; each entry must be https,
47
+/// a loopback http URL, or a custom scheme (native apps).
48
+#[derive(Serialize, Deserialize, Debug, Clone)]
49
+#[tsync]
50
+pub struct AuthAppClientCreate {
51
+ pub name: String,
52
+ pub redirect_uris: String,
53
+}
54
+
55
#[cfg(test)]
56
mod tests {
57
use super::*;
crates/smbcloud-model/src/oauth.rs
+4
@@ -12,6 +12,10 @@ pub struct TokenResponse {
12
pub refresh_token_expires_in: Option<String>,
13
pub scope: String,
14
pub token_type: String,
15
+ /// OIDC identity token, present when the `openid` scope was granted
16
+ /// (smbCloud Auth AuthApp PKCE flow). Absent for plain OAuth responses.
17
+ #[serde(default, skip_serializing_if = "Option::is_none")]
18
+ pub id_token: Option<String>,
19
}
20
21
#[derive(Serialize, Deserialize, Debug, Clone)]