# OAuth + Session zesdex-iam Wiring Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Finish wiring the previously-orphaned `zesdex-iam` crate into `zesdex-backend` as the sole OAuth and session-management implementation, fixing the real bugs discovered in `zesdex-iam`'s current code along the way (hardcoded fake redirect URI, missing CSRF state validation, weak clock-based RNG, no session-lock implementation), then deleting the now-redundant duplicate implementations. **Architecture:** `zesdex-iam` keeps its Clean-Architecture shape (domain entities + repository traits + application services), but gains: (1) a real loopback HTTP listener ported from the working backend code, (2) CSRF-safe `state` handling via a persisted sidecar file (same pattern already used for the PKCE verifier), (3) a CSPRNG via `rand_core::OsRng`, (4) a `FileSystemSessionLockRepository` (previously missing entirely). `zesdex-backend`'s `app/runtime/actions/mod.rs` and `main.rs` are rewired to call through `zesdex-iam` instead of the old `service/oauth/*` module and `zesdex_entities::seaorm::auth::{session,session_lock}`, which are then deleted. **Tech Stack:** Rust, Cargo workspace (`zesdex-iam`, `zesdex-backend`, `zesdex-entities`), `rand_core` (OsRng), existing `reqwest`/`serde_json`/`url`/`sha2`/`base64` deps already in `zesdex-iam`. ## Global Constraints - On-disk file formats/locations for OAuth tokens (`~/.config/zesdex/oauth_{provider}.json`) and sessions (`/sessions//session.json`) MUST NOT change — confirmed byte-compatible between the old and new implementations, so no data migration step is needed, but any change to field names/shapes would break this. - No `#[allow(...)]` lint-bypass attributes may be added for any *new* code in this plan. The existing module-level cast-quad allows already present in touched files may remain (removing those is handled by the separate `2026-07-16-convention-cleanup-docs.md` plan) — don't fix them here, don't add new ones. - Every new `pub fn`/`pub struct` needs a doc comment per CLAUDE.md's Code Documentation rules. - Tests are inline `#[cfg(test)] mod tests` blocks, per CLAUDE.md. - Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` before each commit. - Confirmed via research: the OAuth token currently persisted is **never read back by any other code path** (write-only today), so this rewiring carries zero risk of breaking a downstream consumer. `AppStateRest.sessions` (populated from `Session::list()`) is similarly **never read after being set at startup**, so the type-swap on that field is low-risk. --- ### Task 1: Add a real CSPRNG to `zesdex-iam` **Files:** - Modify: `crates/zesdex-iam/Cargo.toml` - Create: `crates/zesdex-iam/src/infrastructure/rng.rs` - Modify: `crates/zesdex-iam/src/infrastructure/mod.rs` **Interfaces:** - Produces: `pub fn secure_token_hex(n_bytes: usize) -> String` — used by Task 2 for both the PKCE verifier and the CSRF `state` token. - [ ] **Step 1: Add the `rand_core` dependency** In `crates/zesdex-iam/Cargo.toml`, add to `[dependencies]` (matching the version/features already used by `zesdex-libs`): ```toml rand_core = { version = "0.6", features = ["getrandom"] } ``` - [ ] **Step 2: Write the failing test** Create `crates/zesdex-iam/src/infrastructure/rng.rs`: ```rust //! Cryptographically secure random-token generation for OAuth CSRF state //! tokens and PKCE verifiers. use rand_core::{OsRng, RngCore}; /// Generate `n_bytes` of CSPRNG output, hex-encoded. /// /// Why: the previous implementation derived "randomness" from /// `SystemTime::now()` XORed with a monotonic counter — predictable given /// a bounded guess at request time, which undermines both CSRF `state` /// and PKCE verifier unpredictability. `OsRng` draws from the OS entropy /// source (`getrandom`/`/dev/urandom` equivalent) and is the same /// primitive already used correctly for password-salt generation in /// `zesdex-libs::password::hash_password`. /// /// Return: a lowercase hex string of length `2 * n_bytes`. pub fn secure_token_hex(n_bytes: usize) -> String { let mut buf = vec![0u8; n_bytes]; OsRng.fill_bytes(&mut buf); hex::encode(buf) } #[cfg(test)] mod tests { use super::*; #[test] fn secure_token_hex_produces_correct_length() { assert_eq!(secure_token_hex(16).len(), 32); assert_eq!(secure_token_hex(32).len(), 64); } #[test] fn secure_token_hex_is_not_constant() { let a = secure_token_hex(16); let b = secure_token_hex(16); assert_ne!(a, b, "two consecutive calls must not produce the same token"); } } ``` - [ ] **Step 3: Register the module** In `crates/zesdex-iam/src/infrastructure/mod.rs`, add: ```rust pub mod rng; ``` (Read the file first to see existing `pub mod` lines and insert alongside them — `cat crates/zesdex-iam/src/infrastructure/mod.rs`.) - [ ] **Step 4: Run the tests to verify they pass** Run: `cargo test -p zesdex-iam rng:: -- --nocapture` Expected: both tests pass. - [ ] **Step 5: Commit** ```bash git add crates/zesdex-iam/Cargo.toml crates/zesdex-iam/src/infrastructure/rng.rs crates/zesdex-iam/src/infrastructure/mod.rs Cargo.lock git commit -m "feat(iam): tambahkan CSPRNG (OsRng) untuk token state/PKCE" ``` --- ### Task 2: Port the loopback HTTP listener into `zesdex-iam` **Files:** - Create: `crates/zesdex-iam/src/infrastructure/oauth_loopback.rs` - Modify: `crates/zesdex-iam/src/infrastructure/mod.rs` **Interfaces:** - Consumes: nothing new. - Produces: `pub struct LoopbackServer` with `bind() -> std::io::Result`, `redirect_uri(&self) -> String`, `wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result` — used by Task 3's `OAuthServiceImpl` orchestration. - [ ] **Step 1: Write the failing test** This is a verbatim port of `crates/zesdex-backend/src/service/oauth/loopback.rs` (already has working inline tests — read it first: `cat crates/zesdex-backend/src/service/oauth/loopback.rs`). Copy its `#[cfg(test)] mod tests` block into the new file unchanged (only the `use super::*;` import needs no change since the struct/method names are identical). - [ ] **Step 2: Run the copied tests against the not-yet-created module to verify they fail** Run: `cargo test -p zesdex-iam oauth_loopback:: 2>&1 | head -20` Expected: compile error (module doesn't exist yet). - [ ] **Step 3: Create the module** Create `crates/zesdex-iam/src/infrastructure/oauth_loopback.rs` with the full contents of `crates/zesdex-backend/src/service/oauth/loopback.rs` copied verbatim (struct `LoopbackServer`, `bind`, `redirect_uri`, `wait_for_code`, `read_callback`, `extract_code`, `extract_state`, `urlencoding`, plus its tests), with only the module doc comment's crate-relative framing adjusted: ```rust //! Minimal loopback HTTP server for capturing OAuth authorization-code redirects. //! //! Ported from the original `zesdex-backend::service::oauth::loopback` module //! as part of wiring OAuth into `zesdex-iam`'s Clean Architecture. Logic is //! unchanged — see git history for `crates/zesdex-backend/src/service/oauth/loopback.rs` //! (removed in this same change) for the pre-port version. ``` (Do not remove the `#![allow(clippy::cast_*)]` header from the copied file for this task — that cleanup is out of scope here, handled by the convention-cleanup plan.) - [ ] **Step 4: Register the module** In `crates/zesdex-iam/src/infrastructure/mod.rs`, add: ```rust pub mod oauth_loopback; ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `cargo test -p zesdex-iam oauth_loopback:: -- --nocapture` Expected: all ported tests pass identically to how they passed in the old location. - [ ] **Step 6: Commit** ```bash git add crates/zesdex-iam/src/infrastructure/oauth_loopback.rs crates/zesdex-iam/src/infrastructure/mod.rs git commit -m "feat(iam): port LoopbackServer OAuth callback listener dari zesdex-backend" ``` --- ### Task 3: Fix `OAuthServiceImpl` — real redirect URI, CSRF state validation, CSPRNG **Files:** - Modify: `crates/zesdex-iam/src/domain/service.rs` - Modify: `crates/zesdex-iam/src/application/oauth_service.rs` - Modify: `crates/zesdex-iam/src/infrastructure/http/dto.rs` - Modify: `crates/zesdex-iam/src/infrastructure/http/handlers.rs` **Interfaces:** - Consumes: `crate::infrastructure::rng::secure_token_hex` (Task 1), `crate::infrastructure::oauth_loopback::LoopbackServer` (Task 2, used by the *caller* in Task 5, not inside `OAuthServiceImpl` itself — the service stays transport-agnostic; the loopback server is bound by the caller which then passes the real `redirect_uri` in). - Produces: `OAuthService::start_flow(&self, config: &OAuthConfig, redirect_uri: &str) -> anyhow::Result<(String, String)>` (returns `(auth_url, state)`) and `OAuthService::complete_flow(&self, config: &OAuthConfig, redirect_uri: &str, code: &str, state: &str) -> anyhow::Result` — Task 5 depends on these exact signatures. - [ ] **Step 1: Update the trait signatures** In `crates/zesdex-iam/src/domain/service.rs`, replace the `OAuthService` trait: ```rust /// OAuth flow use-case boundary. pub trait OAuthService { /// Start an OAuth authorization-code + PKCE flow for the given /// `redirect_uri` (the caller is responsible for actually listening on /// it — e.g. a bound `LoopbackServer`). Returns `(auth_url, state)`: /// the URL to send the user to, and the CSRF state token that must be /// passed back into `complete_flow` unchanged. fn start_flow(&self, config: &OAuthConfig, redirect_uri: &str) -> anyhow::Result<(String, String)>; /// Complete the OAuth flow: validates `state` against the value /// persisted during `start_flow` (bailing on mismatch — this is the /// CSRF check), then exchanges `code` for a token using the same /// `redirect_uri` passed to `start_flow`. fn complete_flow( &self, config: &OAuthConfig, redirect_uri: &str, code: &str, state: &str, ) -> anyhow::Result; /// Retrieve the currently stored OAuth token (if any). fn get_token(&self) -> anyhow::Result>; } ``` - [ ] **Step 2: Write the failing tests** Add to the bottom of `crates/zesdex-iam/src/application/oauth_service.rs` (create a `#[cfg(test)] mod tests` block — none exists yet): ```rust #[cfg(test)] mod tests { use super::*; use crate::domain::repository::OAuthRepository; use std::cell::RefCell; use std::path::PathBuf; #[derive(Default)] struct FakeOAuthRepo { saved: RefCell>, } impl OAuthRepository for FakeOAuthRepo { fn save_token(&self, _path: &std::path::Path, token: &OAuthToken) -> anyhow::Result<()> { *self.saved.borrow_mut() = Some(token.clone()); Ok(()) } fn load_token(&self, _path: &std::path::Path) -> anyhow::Result> { Ok(self.saved.borrow().clone()) } } fn tmp_token_path() -> PathBuf { std::env::temp_dir().join(format!("zesdex-iam-oauth-test-{}", uuid::Uuid::new_v4())) } #[test] fn complete_flow_rejects_mismatched_state() { let svc = OAuthServiceImpl::new(FakeOAuthRepo::default(), tmp_token_path()); let config = OAuthConfig { auth_url: "https://example.test/authorize".to_string(), ..OAuthConfig::default() }; let (_, _real_state) = svc .start_flow(&config, "http://127.0.0.1:12345/callback") .expect("start_flow should succeed"); let result = svc.complete_flow( &config, "http://127.0.0.1:12345/callback", "some-code", "attacker-supplied-state", ); assert!(result.is_err(), "complete_flow must reject a state that doesn't match what start_flow persisted"); } #[test] fn start_flow_returns_url_containing_the_real_redirect_uri() { let svc = OAuthServiceImpl::new(FakeOAuthRepo::default(), tmp_token_path()); let config = OAuthConfig { auth_url: "https://example.test/authorize".to_string(), ..OAuthConfig::default() }; let (auth_url, state) = svc .start_flow(&config, "http://127.0.0.1:54321/callback") .expect("start_flow should succeed"); assert!( auth_url.contains("127.0.0.1%3A54321") || auth_url.contains("127.0.0.1:54321"), "auth_url must embed the real dynamic redirect_uri, not a hardcoded port-0 placeholder: {auth_url}" ); assert!(!state.is_empty()); } } ``` - [ ] **Step 3: Run the tests to verify they fail** Run: `cargo test -p zesdex-iam oauth_service:: -- --nocapture` Expected: compile errors — `start_flow`/`complete_flow` don't yet match these signatures. - [ ] **Step 4: Rewrite `OAuthServiceImpl`** Replace the full contents of `crates/zesdex-iam/src/application/oauth_service.rs` (keep the file's existing module doc comment and `#![allow(clippy::cast_*)]` header, and keep the private `CodeVerifier`/`CodeChallenge` PKCE helper struct, but change its randomness source and add state persistence): ```rust #![allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap )] //! OAuth flow use-cases. //! //! `OAuthServiceImpl` drives the authorization-code + PKCE flow: //! generating the verifier, building the auth URL, exchanging the code //! for a token, and persisting the result via the injected repository. //! The CSRF `state` token and PKCE verifier are both persisted to sidecar //! files next to `token_path` so `start_flow` and `complete_flow` can be //! two separate calls (the caller — see `zesdex-backend`'s //! `run_oauth_flow` — binds a real loopback listener in between). use std::path::PathBuf; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use sha2::{Digest, Sha256}; use crate::domain::oauth::{OAuthConfig, OAuthToken}; use crate::domain::repository::OAuthRepository; use crate::domain::service::OAuthService; use crate::infrastructure::rng::secure_token_hex; const VERIFIER_LENGTH: usize = 64; /// A randomly generated, base64url-encoded PKCE code verifier. struct CodeVerifier(String); impl CodeVerifier { fn new() -> Self { let bytes = hex::decode(secure_token_hex(VERIFIER_LENGTH)) .unwrap_or_default(); CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes)) } fn as_str(&self) -> &str { &self.0 } /// Derive the S256 code challenge (SHA-256 → base64url). fn challenge(&self) -> String { let mut hasher = Sha256::new(); hasher.update(self.0.as_bytes()); let digest = hasher.finalize(); URL_SAFE_NO_PAD.encode(digest) } } /// Concrete OAuth service backed by a generic token repository. /// /// The code verifier and CSRF state token are each stored to a sidecar /// file (`token_path` with `.verifier`/`.state` extensions respectively) /// in `start_flow` and consumed + deleted in `complete_flow`. pub struct OAuthServiceImpl { pub token_repo: R, pub token_path: PathBuf, } impl OAuthServiceImpl { /// Create a new OAuth service. /// /// * `token_repo` — repository used to persist / load tokens. /// * `token_path` — file path where the token JSON is stored. pub fn new(token_repo: R, token_path: PathBuf) -> Self { OAuthServiceImpl { token_repo, token_path, } } fn sidecar_path(&self, suffix: &str) -> PathBuf { let mut p = self.token_path.clone(); let ext = p .extension() .map(|e| format!("{}.{suffix}", e.to_string_lossy())) .unwrap_or_else(|| suffix.to_string()); p.set_extension(ext); p } fn verifier_path(&self) -> PathBuf { self.sidecar_path("verifier") } fn state_path(&self) -> PathBuf { self.sidecar_path("state") } } impl OAuthService for OAuthServiceImpl { fn start_flow(&self, config: &OAuthConfig, redirect_uri: &str) -> anyhow::Result<(String, String)> { if config.auth_url.is_empty() { anyhow::bail!("OAuth auth_url is empty"); } let verifier = CodeVerifier::new(); let challenge = verifier.challenge(); let state = secure_token_hex(16); if let Some(parent) = self.token_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(self.verifier_path(), verifier.as_str())?; std::fs::write(self.state_path(), &state)?; let mut url = url::Url::parse(&config.auth_url) .map_err(|e| anyhow::anyhow!("invalid auth_url '{}': {e}", config.auth_url))?; url.query_pairs_mut() .append_pair("response_type", "code") .append_pair("client_id", &config.client_id) .append_pair("redirect_uri", redirect_uri) .append_pair("scope", &config.scopes.join(" ")) .append_pair("state", &state) .append_pair("code_challenge_method", "S256") .append_pair("code_challenge", &challenge); Ok((url.to_string(), state)) } fn complete_flow( &self, config: &OAuthConfig, redirect_uri: &str, code: &str, state: &str, ) -> anyhow::Result { let state_path = self.state_path(); let expected_state = std::fs::read_to_string(&state_path) .map_err(|e| anyhow::anyhow!("failed to read persisted OAuth state: {e}"))?; if expected_state != state { anyhow::bail!("OAuth state mismatch — possible CSRF attack"); } let verifier_path = self.verifier_path(); let verifier = std::fs::read_to_string(&verifier_path) .map_err(|e| anyhow::anyhow!("failed to read PKCE verifier: {e}"))?; let client = reqwest::blocking::Client::new(); let mut params = std::collections::HashMap::new(); params.insert("grant_type", "authorization_code"); params.insert("code", code); params.insert("redirect_uri", redirect_uri); params.insert("client_id", &config.client_id); params.insert("code_verifier", &verifier); if let Some(ref secret) = config.client_secret { params.insert("client_secret", secret); } let resp = client .post(&config.token_url) .form(¶ms) .send() .map_err(|e| anyhow::anyhow!("token request failed: {e}"))?; let status = resp.status(); let body: serde_json::Value = resp .json() .map_err(|e| anyhow::anyhow!("failed to parse token response: {e}"))?; if !status.is_success() { anyhow::bail!("token endpoint returned {status}: {body}"); } let access_token = body["access_token"] .as_str() .ok_or_else(|| anyhow::anyhow!("response missing access_token"))? .to_string(); let expires_in = body["expires_in"].as_u64().unwrap_or(3600); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let token = OAuthToken { access_token, refresh_token: body["refresh_token"].as_str().map(String::from), expires_at: now + expires_in, token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), }; self.token_repo.save_token(&self.token_path, &token)?; let _ = std::fs::remove_file(&verifier_path); let _ = std::fs::remove_file(&state_path); Ok(token) } fn get_token(&self) -> anyhow::Result> { self.token_repo.load_token(&self.token_path) } } ``` - [ ] **Step 5: Update the dead HTTP scaffolding to keep the crate compiling** In `crates/zesdex-iam/src/infrastructure/http/dto.rs`, update `OAuthStartRequest` and `OAuthCompleteRequest`: ```rust /// Request body for starting an OAuth flow. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthStartRequest { pub config: OAuthConfig, pub redirect_uri: String, } /// Response containing the authorization URL and CSRF state for an OAuth flow. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthStartResponse { pub auth_url: String, pub state: String, } /// Request body for completing an OAuth flow with an authorization code. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthCompleteRequest { pub config: OAuthConfig, pub redirect_uri: String, pub code: String, pub state: String, } ``` In `crates/zesdex-iam/src/infrastructure/http/handlers.rs`, update the two handlers: ```rust /// Handle a start-OAuth-flow request. pub fn handle_start_oauth( service: &O, req: OAuthStartRequest, ) -> anyhow::Result { let (auth_url, state) = service.start_flow(&req.config, &req.redirect_uri)?; Ok(OAuthStartResponse { auth_url, state }) } /// Handle a complete-OAuth-flow request. pub fn handle_complete_oauth( service: &O, req: OAuthCompleteRequest, ) -> anyhow::Result { let token = service.complete_flow(&req.config, &req.redirect_uri, &req.code, &req.state)?; Ok(OAuthTokenResponse { token }) } ``` - [ ] **Step 6: Run the tests to verify they pass** Run: `cargo test -p zesdex-iam -- --nocapture` Expected: all tests in `oauth_service::tests` and `oauth_loopback::tests` pass; crate compiles with no errors. - [ ] **Step 7: Run clippy** Run: `cargo clippy -p zesdex-iam -- -D warnings` Expected: no new warnings beyond the pre-existing cast-quad allows already in the file headers. - [ ] **Step 8: Commit** ```bash git add crates/zesdex-iam/src/domain/service.rs crates/zesdex-iam/src/application/oauth_service.rs crates/zesdex-iam/src/infrastructure/http/dto.rs crates/zesdex-iam/src/infrastructure/http/handlers.rs git commit -m "fix(iam): redirect_uri dinamis + validasi CSRF state di OAuthServiceImpl" ``` --- ### Task 4: Harden OAuth token file permissions **Files:** - Modify: `crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs` **Interfaces:** - Consumes: nothing new. - Produces: `FileSystemOAuthRepository::save_token` unchanged signature, additional `0o600` permission set after write (Unix-only). - [ ] **Step 1: Write the failing test** Add to `crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs` (create `#[cfg(test)] mod tests` — none exists yet): ```rust #[cfg(test)] mod tests { use super::*; use crate::domain::oauth::OAuthToken; #[test] #[cfg(unix)] fn save_token_sets_owner_only_permissions() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("zesdex-iam-perm-test-{}", uuid::Uuid::new_v4())); let path = dir.join("oauth_test.json"); let repo = FileSystemOAuthRepository::new(); let token = OAuthToken { access_token: "secret".to_string(), refresh_token: None, expires_at: 0, token_type: "Bearer".to_string(), }; repo.save_token(&path, &token).expect("save_token should succeed"); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "token file must be readable/writable by owner only, got {mode:o}"); let _ = std::fs::remove_dir_all(&dir); } } ``` Add `uuid` to `[dependencies]` of `crates/zesdex-iam/Cargo.toml` if not already present (check first: `grep uuid crates/zesdex-iam/Cargo.toml` — it's already there per the existing `Cargo.toml` workspace deps, confirm before editing). - [ ] **Step 2: Run the test to verify it fails** Run: `cargo test -p zesdex-iam save_token_sets_owner_only -- --nocapture` Expected: assertion failure — current mode is whatever the process umask produces (commonly `0o644`). - [ ] **Step 3: Set restrictive permissions after write** In `crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs`, update `save_token`: ```rust fn save_token(&self, path: &Path, token: &OAuthToken) -> anyhow::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let data = serde_json::to_string_pretty(token)?; let tmp = path.with_extension("tmp"); std::fs::write(&tmp, data)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; } let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, path)?; if let Some(parent) = path.parent() { let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); } Ok(()) } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `cargo test -p zesdex-iam save_token_sets_owner_only -- --nocapture` Expected: pass on Linux/macOS. (On non-Unix the `#[cfg(unix)]` test is skipped, which is correct — Windows ACLs are a different mechanism, out of scope.) - [ ] **Step 5: Commit** ```bash git add crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs git commit -m "fix(iam): set permission 0600 pada file token OAuth" ``` --- ### Task 5: Write `FileSystemSessionLockRepository` **Context:** `SessionLockRepository` (the trait) has no implementation anywhere in the workspace. Port the atomic-create + stale-PID-recovery logic verbatim from `crates/zesdex-entities/src/seaorm/auth/session_lock.rs`. **Files:** - Create: `crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs` - Modify: `crates/zesdex-iam/src/infrastructure/persistence/mod.rs` **Interfaces:** - Consumes: `crate::domain::repository::SessionLockRepository` trait (already defined). - Produces: `pub struct FileSystemSessionLockRepository` implementing `try_lock`/`unlock`/`is_alive` — used by Task 7. - [ ] **Step 1: Write the failing tests** Create `crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs` with this test module (port of the original entities tests, adapted to the repository shape — check `crates/zesdex-entities/src/seaorm/auth/session_lock.rs`'s own `#[cfg(test)]` block first for any additional cases to carry over: `grep -A 40 "mod tests" crates/zesdex-entities/src/seaorm/auth/session_lock.rs`): ```rust #[cfg(test)] mod tests { use super::*; fn tmp_dir() -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("zesdex-iam-lock-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn try_lock_succeeds_when_no_lock_file_exists() { let dir = tmp_dir(); let repo = FileSystemSessionLockRepository::new(); assert!(repo.try_lock(&dir).unwrap()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn try_lock_fails_when_held_by_a_live_process() { let dir = tmp_dir(); let repo = FileSystemSessionLockRepository::new(); assert!(repo.try_lock(&dir).unwrap()); // A second acquisition attempt (simulating our own still-live PID) // must fail since the lock file already holds a live PID. assert!(!repo.try_lock(&dir).unwrap()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn try_lock_recovers_a_stale_lock() { let dir = tmp_dir(); let repo = FileSystemSessionLockRepository::new(); // Write a lock file with a PID that cannot possibly be alive. std::fs::write(dir.join(".lock"), "999999999").unwrap(); assert!(repo.try_lock(&dir).unwrap(), "a stale lock (dead PID) must be recoverable"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn unlock_removes_the_lock_file() { let dir = tmp_dir(); let repo = FileSystemSessionLockRepository::new(); assert!(repo.try_lock(&dir).unwrap()); repo.unlock(&dir).unwrap(); assert!(!dir.join(".lock").exists()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn is_alive_returns_true_for_current_process() { let repo = FileSystemSessionLockRepository::new(); assert!(repo.is_alive(std::process::id())); } #[test] fn is_alive_returns_false_for_implausible_pid() { let repo = FileSystemSessionLockRepository::new(); assert!(!repo.is_alive(999_999_999)); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `cargo test -p zesdex-iam session_lock_repo:: 2>&1 | head -20` Expected: compile error (`FileSystemSessionLockRepository` doesn't exist yet). - [ ] **Step 3: Implement `FileSystemSessionLockRepository`** Add above the test module in the same file: ```rust //! Filesystem-backed `SessionLockRepository` implementation. //! //! Ported from `zesdex_entities::seaorm::auth::session_lock::SessionLock`'s //! inherent methods — same atomic-create-based locking, same stale-PID //! recovery via `libc::kill(pid, 0)` plus a `/proc//exe` identity //! check to guard against PID reuse. This repository is stateless (no //! `Drop`-based auto-release) — callers that need panic-safety should wrap //! acquisition in their own RAII guard (see `zesdex-backend`'s //! `main.rs::SessionLockGuard`, added in a later task of this plan). use std::fs; use std::io::Write; use std::path::Path; use crate::domain::repository::SessionLockRepository; /// Concrete filesystem session-lock repository, using a PID file /// (`/.lock`) with atomic `O_CREAT|O_EXCL` acquisition. #[derive(Debug, Clone, Default)] pub struct FileSystemSessionLockRepository; impl FileSystemSessionLockRepository { /// Create a new filesystem session-lock repository. pub fn new() -> Self { FileSystemSessionLockRepository } } impl SessionLockRepository for FileSystemSessionLockRepository { fn try_lock(&self, session_dir: &Path) -> anyhow::Result { let path = session_dir.join(".lock"); let pid = std::process::id(); match fs::OpenOptions::new().create_new(true).write(true).open(&path) { Ok(mut file) => { write!(file, "{pid}")?; file.sync_all()?; return Ok(true); } Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(e) => return Err(e.into()), } let content = fs::read_to_string(&path).unwrap_or_default(); if let Ok(existing_pid) = content.trim().parse::() { if self.is_alive(existing_pid) { return Ok(false); } } let tmp = path.with_extension("lock.tmp"); { let mut tmp_file = fs::OpenOptions::new().create(true).truncate(true).write(true).open(&tmp)?; write!(tmp_file, "{pid}")?; tmp_file.sync_all()?; } fs::rename(&tmp, &path)?; if let Some(parent) = path.parent() { let _ = fs::File::open(parent).and_then(|d| d.sync_all()); } Ok(true) } fn unlock(&self, session_dir: &Path) -> anyhow::Result<()> { let path = session_dir.join(".lock"); let _ = fs::remove_file(path); Ok(()) } fn is_alive(&self, pid: u32) -> bool { // SAFETY: `libc::kill(pid, 0)` sends no signal; it only probes // whether the process exists and is signalable by us. if unsafe { libc::kill(pid as i32, 0) != 0 } { return false; } let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe")); if let Ok(target) = std::fs::read_link(&proc_exe) { if let Ok(exe) = std::env::current_exe() { if target != exe { return false; } } } true } } ``` Add `libc` to `crates/zesdex-iam/Cargo.toml`'s `[dependencies]` if not already present — check first: `grep libc crates/zesdex-iam/Cargo.toml` (it's already listed per the crate's current dependency list). - [ ] **Step 4: Register the module** In `crates/zesdex-iam/src/infrastructure/persistence/mod.rs`, add: ```rust pub mod session_lock_repo; ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `cargo test -p zesdex-iam session_lock_repo:: -- --nocapture` Expected: all 6 tests pass. - [ ] **Step 6: Commit** ```bash git add crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs crates/zesdex-iam/src/infrastructure/persistence/mod.rs git commit -m "feat(iam): implementasikan FileSystemSessionLockRepository (sebelumnya belum ada implementasi)" ``` --- ### Task 6: Rewire `run_oauth_flow` in `zesdex-backend` to use `zesdex-iam` **Files:** - Modify: `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines ~1667-1809 per current line numbers — re-confirm with `grep -n "fn run_oauth_flow\|fn rand_bytes" crates/zesdex-backend/src/app/runtime/actions/mod.rs` before editing, since earlier tasks/plans may have shifted line numbers) - Modify: `crates/zesdex-backend/Cargo.toml` (confirm `zesdex-iam` already listed as a dependency — it is, per `Cargo.toml:9` — no change needed, just verify) **Interfaces:** - Consumes: `zesdex_iam::application::oauth_service::OAuthServiceImpl`, `zesdex_iam::infrastructure::persistence::oauth_repo::FileSystemOAuthRepository`, `zesdex_iam::infrastructure::oauth_loopback::LoopbackServer`, `zesdex_iam::domain::service::OAuthService` (trait, for `start_flow`/`complete_flow` method resolution). - Produces: `run_oauth_flow` keeps its existing signature (`fn run_oauth_flow(provider: &str) -> anyhow::Result`) so `Action::StartOAuth`'s handler (line ~235) needs no change. - [ ] **Step 1: Confirm current line numbers** Run: `grep -n "fn run_oauth_flow\|fn rand_bytes\|use.*service::oauth" crates/zesdex-backend/src/app/runtime/actions/mod.rs` - [ ] **Step 2: Replace the OAuth-related imports** Remove these three lines (from the function's surrounding `use` statements, confirmed at lines 1668-1670 in the pre-change file): ```rust use crate::service::oauth::manager::{OAuthConfig, OAuthManager}; use crate::service::oauth::loopback::LoopbackServer; use crate::service::oauth::pkce::CodeVerifier; ``` Replace with: ```rust use zesdex_iam::domain::oauth::OAuthConfig; use zesdex_iam::domain::service::OAuthService; use zesdex_iam::application::oauth_service::OAuthServiceImpl; use zesdex_iam::infrastructure::persistence::oauth_repo::FileSystemOAuthRepository; use zesdex_iam::infrastructure::oauth_loopback::LoopbackServer; ``` - [ ] **Step 3: Rewrite the body of `run_oauth_flow`** Keep the existing provider-config `match` block (lines building `OAuthConfig` for `"zen"`/`"opencode"`/`"openai"`/other — unchanged, since `zesdex_iam::domain::oauth::OAuthConfig` has an identical shape to the old `service::oauth::manager::OAuthConfig`). Replace everything from `let server = LoopbackServer::bind()?;` onward (previously lines 1706-1742) with: ```rust let server = LoopbackServer::bind()?; let redirect_uri = server.redirect_uri(); let token_path = dirs::config_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("zesdex") .join(format!("oauth_{provider}.json")); let oauth_service = OAuthServiceImpl::new(FileSystemOAuthRepository::new(), token_path); let (auth_url, state) = oauth_service.start_flow(&config, &redirect_uri)?; if auth_url.is_empty() { tracing::warn!("[oauth] auth_url was empty for provider '{}'", provider); } else if webbrowser::open(&auth_url).is_err() { tracing::warn!( "[oauth] could not open browser for '{}'; user must open URL manually:\n{}", provider, auth_url ); } let code = server.wait_for_code(120_000, &state)?; oauth_service .complete_flow(&config, &redirect_uri, &code, &state) .map_err(|e| anyhow::anyhow!("{e}"))?; Ok(format!("Successfully authenticated with {provider}.")) ``` - [ ] **Step 4: Delete the now-unused weak-RNG helper** Delete the `fn rand_bytes(n: usize) -> Vec { ... }` function entirely (previously lines 1791-1809) — it has no remaining callers after Step 3. - [ ] **Step 5: Verify the crate compiles** Run: `cargo check -p zesdex-backend` Expected: no errors. If `OAuthConfig`'s field set doesn't line up exactly with the provider-config `match` block's struct-literal syntax, the compiler will report the exact mismatch — the two `OAuthConfig` shapes were confirmed field-identical during planning, so no mismatch is expected, but re-verify since this is the one place both hand-written config literals and the moved type meet. - [ ] **Step 6: Manual smoke test** Since this touches the actual login flow and there's no existing automated test for `run_oauth_flow` (confirmed no test coverage existed before this change either), do a manual check: Run: `cargo run -p zesdex-backend -- --help` (or the equivalent entry point) to confirm the binary still starts, then (if you have a test OAuth provider configured via env vars) exercise `/login ` in the TUI and confirm the browser opens and the flow completes. If no test provider is available, at minimum confirm via `RUST_LOG=debug` that `[oauth]` log lines appear as expected and no panic occurs when triggering `/login unknownprovider` (should hit the `other =>` branch's env-var-missing error path cleanly). - [ ] **Step 7: Commit** ```bash git add crates/zesdex-backend/src/app/runtime/actions/mod.rs git commit -m "refactor(backend): alihkan run_oauth_flow ke zesdex-iam OAuthServiceImpl" ``` --- ### Task 7: Rewire session management to `zesdex-iam` **Files:** - Modify: `crates/zesdex-backend/src/main.rs` (session-lock/list call sites: confirm current line numbers first with `grep -n "session_lock\|model::session::" crates/zesdex-backend/src/main.rs`) - Modify: `crates/zesdex-backend/src/app/state/rest.rs` (`sessions` field, line ~60) - Modify: `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (`save_current_session`, ~lines 1638-1651) - Modify: `crates/zesdex-backend/src/model/mod.rs` (remove `session`/`session_lock` re-exports) **Interfaces:** - Consumes: `zesdex_iam::domain::session::Session`, `zesdex_iam::domain::repository::{SessionRepository, SessionLockRepository}`, `zesdex_iam::infrastructure::persistence::{session_repo::FileSystemSessionRepository, session_lock_repo::FileSystemSessionLockRepository}` (from Task 5). - Produces: `AppStateRest.sessions: Vec` (changed from the old entities type) — confirmed dead-after-assignment field, so no other file needs updating as a consequence. - [ ] **Step 1: Add a panic-safe lock guard to `main.rs`** The old `entities::SessionLock` had a `Drop` impl auto-releasing the lock; the new repository is stateless per-call and has no such guard. Add this small RAII wrapper near the top of `crates/zesdex-backend/src/main.rs` (after the existing `use` statements — read the file's current top section first: `head -40 crates/zesdex-backend/src/main.rs`): ```rust /// RAII guard that releases a session lock on drop, restoring the /// panic-safety net the old `entities::SessionLock`'s `Drop` impl provided /// (the `SessionLockRepository` trait itself is stateless and has no /// `Drop`, since a repository isn't tied to any one lock's lifetime). struct SessionLockGuard<'a, L: zesdex_iam::domain::repository::SessionLockRepository> { lock_repo: &'a L, session_dir: std::path::PathBuf, } impl Drop for SessionLockGuard<'_, L> { fn drop(&mut self) { let _ = self.lock_repo.unlock(&self.session_dir); } } ``` - [ ] **Step 2: Replace the `run_single_process()` session-lock/list call sites** Read the current code first: `grep -n -B2 -A2 "session_lock\|model::session::Session::list" crates/zesdex-backend/src/main.rs` Replace (previously around lines 101-113): ```rust let session_lock = model::session_lock::SessionLock::new(&session_dir); if !session_lock.try_lock()? { anyhow::bail!("another zesdex process already holds the lock for this session"); } ``` with: ```rust let lock_repo = zesdex_iam::infrastructure::persistence::session_lock_repo::FileSystemSessionLockRepository::new(); if !lock_repo.try_lock(&session_dir)? { anyhow::bail!("another zesdex process already holds the lock for this session"); } let _session_lock_guard = SessionLockGuard { lock_repo: &lock_repo, session_dir: session_dir.clone() }; ``` Replace (previously line 113): ```rust state.sessions = model::session::Session::list(&store.base_dir); ``` with: ```rust let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new(); state.sessions = session_repo.list_sessions(&store.base_dir).unwrap_or_default(); ``` Replace the explicit unlock at shutdown (previously line 142): ```rust session_lock.unlock(); ``` with removing the line entirely — `_session_lock_guard`'s `Drop` now handles this automatically. (If the surrounding code needs an explicit early-unlock point before the guard would naturally drop, keep an explicit `lock_repo.unlock(&session_dir);` call instead of relying solely on `Drop` — check the exact control flow around line 142 first to decide; the guard covers the panic/early-return cases either way.) - [ ] **Step 3: Repeat for `run_daemon()`** Apply the identical substitution pattern to the daemon-mode call sites (previously lines 435-478): construct `lock_repo`/`session_repo` the same way, wrap in a `SessionLockGuard`, replace `model::session::Session::list` with `session_repo.list_sessions(...)`. - [ ] **Step 4: Update `AppStateRest.sessions` field type** In `crates/zesdex-backend/src/app/state/rest.rs`, change (line ~60): ```rust pub sessions: Vec, ``` to: ```rust pub sessions: Vec, ``` - [ ] **Step 5: Update `save_current_session` in `actions/mod.rs`** Read the current function first: `grep -n -A 15 "fn save_current_session" crates/zesdex-backend/src/app/runtime/actions/mod.rs` Replace the body's session construction/save/path-derivation calls: ```rust let session = crate::model::session::Session::new(state.session_id.clone(), "session".to_string()); let _ = session.save(&base); let conv_path = session.conversation_path(&base); ``` with: ```rust let session = zesdex_iam::domain::session::Session::new(state.session_id.clone(), "session".to_string()); let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new(); let _ = session_repo.save_session(&base, &session); let conv_path = session.conversation_path(&base); ``` (`Session::new` and `.conversation_path(&base)` remain inherent methods on the domain struct in both the old and new versions — confirmed identical signatures during planning — only the *save* call moves from an inherent method to the repository.) - [ ] **Step 6: Remove the now-unused re-exports** In `crates/zesdex-backend/src/model/mod.rs`, remove: ```rust pub mod session { pub use zesdex_entities::seaorm::auth::session::*; } pub mod session_lock { pub use zesdex_entities::seaorm::auth::session_lock::*; } ``` - [ ] **Step 7: Verify no remaining references** Run: `grep -rn "model::session::\|model::session_lock::" crates/zesdex-backend/src` Expected: no output (all call sites migrated in Steps 2-5). - [ ] **Step 8: Build and test** Run: `cargo check -p zesdex-backend && cargo test -p zesdex-backend` Expected: no errors, all existing tests pass. - [ ] **Step 9: Manual smoke test** Run the TUI normally (`cargo run -p zesdex-backend`) and confirm: (a) it starts without a lock error on first run, (b) starting a second instance against the same session directory correctly reports the lock conflict, (c) exiting cleanly releases the lock (starting a third instance afterward succeeds). - [ ] **Step 10: Commit** ```bash git add crates/zesdex-backend/src/main.rs crates/zesdex-backend/src/app/state/rest.rs crates/zesdex-backend/src/app/runtime/actions/mod.rs crates/zesdex-backend/src/model/mod.rs git commit -m "refactor(backend): alihkan manajemen session ke zesdex-iam (SessionRepository/SessionLockRepository)" ``` --- ### Task 8: Delete now-dead duplicate OAuth/session code **Files:** - Delete: `crates/zesdex-backend/src/service/oauth/mod.rs`, `manager.rs`, `loopback.rs`, `pkce.rs` (whole `service/oauth/` directory) - Delete: `crates/zesdex-entities/src/seaorm/auth/oauth.rs` - Modify: `crates/zesdex-entities/src/seaorm/auth/mod.rs` (remove the `oauth` module's `pub use`/`pub mod`) - Modify: `crates/zesdex-backend/src/service/mod.rs` (remove `pub mod oauth;` if present) **Interfaces:** none — pure deletion, verified zero remaining references. - [ ] **Step 1: Verify zero remaining references to the old OAuth module** Run: `grep -rn "service::oauth\|service_oauth" crates/zesdex-backend/src` Expected: no output (Task 6 already migrated the only call site). - [ ] **Step 2: Verify zero remaining references to the dead entities OAuth duplicate** Run: `grep -rln "seaorm::auth::oauth\|auth::oauth::" crates --include='*.rs'` Expected: no output (confirmed dead during research — this file was never referenced outside itself). - [ ] **Step 3: Delete the files** ```bash git rm -r crates/zesdex-backend/src/service/oauth git rm crates/zesdex-entities/src/seaorm/auth/oauth.rs ``` - [ ] **Step 4: Remove dangling module declarations** In `crates/zesdex-backend/src/service/mod.rs`, remove any `pub mod oauth;` line (check first: `cat crates/zesdex-backend/src/service/mod.rs`). In `crates/zesdex-entities/src/seaorm/auth/mod.rs`, remove the `oauth` module declaration/re-export (check first: `cat crates/zesdex-entities/src/seaorm/auth/mod.rs`). - [ ] **Step 5: Build the whole workspace** Run: `cargo build --workspace` Expected: no errors — this is the final check that nothing else referenced the deleted files. - [ ] **Step 6: Run the full test suite** Run: `cargo test --workspace` Expected: all pass. - [ ] **Step 7: Run clippy across the workspace** Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: no new warnings (pre-existing cast-quad allows in untouched files are out of scope for this plan). - [ ] **Step 8: Commit** ```bash git add -A git commit -m "chore: hapus implementasi OAuth/session lama yang sudah digantikan zesdex-iam" ```