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 separate2026-07-16-convention-cleanup-docs.mdplan) — don't fix them here, don't add new ones. - Every new
pub fn/pub structneeds a doc comment per CLAUDE.md's Code Documentation rules. - Tests are inline
#[cfg(test)] mod testsblocks, per CLAUDE.md. - Run
cargo test --workspaceandcargo clippy --workspace --all-targets -- -D warningsbefore 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 fromSession::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 CSRFstatetoken. -
Step 1: Add the
rand_coredependency
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 LoopbackServerwithbind() -> 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'sOAuthServiceImplorchestration. -
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 insideOAuthServiceImplitself — the service stays transport-agnostic; the loopback server is bound by the caller which then passes the realredirect_uriin). -
Produces:
OAuthService::start_flow(&self, config: &OAuthConfig, redirect_uri: &str) -> anyhow::Result<(String, String)>(returns(auth_url, state)) andOAuthService::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(¶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<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_tokenunchanged signature, additional0o600permission 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::SessionLockRepositorytrait (already defined). -
Produces:
pub struct FileSystemSessionLockRepositoryimplementingtry_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 withgrep -n "fn run_oauth_flow\|fn rand_bytes" crates/zesdex-backend/src/app/runtime/actions/mod.rsbefore editing, since earlier tasks/plans may have shifted line numbers) - Modify:
crates/zesdex-backend/Cargo.toml(confirmzesdex-iamalready listed as a dependency — it is, perCargo.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, forstart_flow/complete_flowmethod resolution). -
Produces:
run_oauth_flowkeeps its existing signature (fn run_oauth_flow(provider: &str) -> anyhow::Result<String>) soAction::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 withgrep -n "session_lock\|model::session::" crates/zesdex-backend/src/main.rs) - Modify:
crates/zesdex-backend/src/app/state/rest.rs(sessionsfield, 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(removesession/session_lockre-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.sessionsfield 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_sessioninactions/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(wholeservice/oauth/directory) - Delete:
crates/zesdex-entities/src/seaorm/auth/oauth.rs - Modify:
crates/zesdex-entities/src/seaorm/auth/mod.rs(remove theoauthmodule'spub use/pub mod) - Modify:
crates/zesdex-backend/src/service/mod.rs(removepub 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"