Files
zesdex/docs/superpowers/plans/2026-07-16-oauth-session-iam-wiring.md
T

46 KiB

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 (<base_dir>/sessions/<id>/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):

rand_core = { version = "0.6", features = ["getrandom"] }
  • Step 2: Write the failing test

Create crates/zesdex-iam/src/infrastructure/rng.rs:

//! 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:

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
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<Self>, redirect_uri(&self) -> String, wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> — 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:

//! 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:

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
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<OAuthToken> — 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:

/// 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<OAuthToken>;

    /// Retrieve the currently stored OAuth token (if any).
    fn get_token(&self) -> anyhow::Result<Option<OAuthToken>>;
}
  • 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):

#[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<Option<OAuthToken>>,
    }
    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<Option<OAuthToken>> {
            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):

#![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<R: OAuthRepository> {
    pub token_repo: R,
    pub token_path: PathBuf,
}

impl<R: OAuthRepository> OAuthServiceImpl<R> {
    /// 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<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
    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<OAuthToken> {
        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(&params)
            .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<Option<OAuthToken>> {
        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:

/// 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:

/// Handle a start-OAuth-flow request.
pub fn handle_start_oauth<O: OAuthService>(
    service: &O,
    req: OAuthStartRequest,
) -> anyhow::Result<OAuthStartResponse> {
    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<O: OAuthService>(
    service: &O,
    req: OAuthCompleteRequest,
) -> anyhow::Result<OAuthTokenResponse> {
    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
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):

#[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:

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

#[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:

//! 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/<pid>/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
/// (`<session_dir>/.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<bool> {
        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::<u32>() {
            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:

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
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<String>) 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):

use crate::service::oauth::manager::{OAuthConfig, OAuthManager};
use crate::service::oauth::loopback::LoopbackServer;
use crate::service::oauth::pkce::CodeVerifier;

Replace with:

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:

    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<u8> { ... } 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 <provider> 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
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<zesdex_iam::domain::session::Session> (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):

/// 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<L: zesdex_iam::domain::repository::SessionLockRepository> 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):

    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:

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

    state.sessions = model::session::Session::list(&store.base_dir);

with:

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

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

    pub sessions: Vec<crate::model::session::Session>,

to:

    pub sessions: Vec<zesdex_iam::domain::session::Session>,
  • 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:

    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:

    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:

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
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
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
git add -A
git commit -m "chore: hapus implementasi OAuth/session lama yang sudah digantikan zesdex-iam"