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

1146 lines
46 KiB
Markdown

# 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`):
```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<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:
```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<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:
```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<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):
```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<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):
```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<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`:
```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<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**
```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/<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:
```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<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):
```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<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**
```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<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`):
```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<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):
```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<crate::model::session::Session>,
```
to:
```rust
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:
```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"
```