@smbcloud / cli / commits / 98a9170

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)]