diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index a02273da8..830822be0 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -663,6 +663,72 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/auth/sessions: + get: + operationId: listAuthSessions + tags: [Auth] + summary: List authenticated sessions + description: Returns the current browser session and active CLI session chains for the authenticated user. + responses: + "200": + description: Authenticated sessions known to the server + content: + application/json: + schema: + $ref: "#/components/schemas/AuthSessionsResponse" + "401": + description: Not authenticated + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/auth/sessions/{id}: + delete: + operationId: deleteAuthSession + tags: [Auth] + summary: Revoke an authenticated session + description: Revokes an active CLI session chain. Browser sessions are not revocable in this API version. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: Session revoked + "400": + description: Malformed or non-revocable session id + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Not authenticated + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Session not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/demo/toggle: post: operationId: toggleDemo @@ -3727,6 +3793,59 @@ components: demoMode: type: boolean + AuthSessionsResponse: + type: object + required: + - sessions + properties: + sessions: + type: array + items: + $ref: "#/components/schemas/AuthSession" + + AuthSession: + type: object + required: + - id + - kind + - current + - provider + - login + - label + - createdAt + - lastSeenAt + - expiresAt + - revocable + properties: + id: + type: string + kind: + type: string + enum: [browser, cli] + current: + type: boolean + provider: + type: string + example: github + login: + type: string + label: + type: string + userAgent: + type: string + nullable: true + createdAt: + type: string + format: date-time + lastSeenAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + revocable: + type: boolean + AuthSessionUser: description: Browser session user profile. type: object diff --git a/docs/superpowers/plans/2026-05-10-auth-sessions-backend.md b/docs/superpowers/plans/2026-05-10-auth-sessions-backend.md new file mode 100644 index 000000000..46b4eb55e --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-auth-sessions-backend.md @@ -0,0 +1,213 @@ +# Unified Auth Sessions Backend 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:** Add a backend API that returns a unified list of authenticated Fabro sessions the server currently knows about. + +**Architecture:** The server will expose `/api/v1/auth/sessions` as the canonical session list API. V1 combines the current browser cookie session with active CLI refresh-token chains, using a normalized response shape that the frontend can render without knowing storage details. Browser sessions remain synthetic and non-revocable until durable browser-session storage exists. + +**Tech Stack:** Rust, Axum, OpenAPI/progenitor, SlateDB-backed `fabro-store`, `cargo nextest`. + +**Frontend dependency:** This plan defines the API contract consumed by [2026-05-10-auth-sessions-frontend.md](2026-05-10-auth-sessions-frontend.md). Complete this backend plan before implementing the frontend plan. + +--- + +## Contract + +Add this endpoint pair: + +- `GET /api/v1/auth/sessions` +- `DELETE /api/v1/auth/sessions/{id}` + +Add these response types to `docs/public/api-reference/fabro-api.yaml`: + +```yaml +AuthSessionsResponse: + type: object + required: [sessions] + properties: + sessions: + type: array + items: + $ref: "#/components/schemas/AuthSession" + +AuthSession: + type: object + required: + - id + - kind + - current + - provider + - login + - label + - createdAt + - lastSeenAt + - expiresAt + - revocable + properties: + id: + type: string + kind: + type: string + enum: [browser, cli] + current: + type: boolean + provider: + type: string + example: github + login: + type: string + label: + type: string + userAgent: + type: string + nullable: true + createdAt: + type: string + format: date-time + lastSeenAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + revocable: + type: boolean +``` + +Use these IDs: + +- Current browser session: `browser:current` +- CLI refresh-token chain: `cli:` + +## Tasks + +### Task 1: Add Store Support For Active CLI Sessions + +**Files:** +- Modify: `lib/crates/fabro-store/src/slate/auth_tokens.rs` + +- [x] Add a public store method that scans refresh tokens and returns active CLI session rows for one identity. +- [x] Treat a token as active when `used == false` and `expires_at > now`. +- [x] Group by `chain_id` and return the newest active token per chain by `last_used_at`. +- [x] Filter by `identity` equality with the authenticated user identity. +- [x] Add unit tests covering active, expired, used, duplicate-chain, and other-identity tokens. +- [x] Run: + +```bash +cargo nextest run -p fabro-store auth_tokens +``` + +Expected: `PASS`. + +### Task 2: Define The OpenAPI Contract + +**Files:** +- Modify: `docs/public/api-reference/fabro-api.yaml` +- Generated by build: `lib/crates/fabro-api/src/generated.rs` + +- [x] Add `GET /api/v1/auth/sessions` under the existing Auth section. +- [x] Add `DELETE /api/v1/auth/sessions/{id}` under the same section. +- [x] Add `AuthSessionsResponse` and `AuthSession` schemas exactly matching the contract above. +- [x] Run: + +```bash +cargo build -p fabro-api +``` + +Expected: build succeeds and generated Rust API types include the new schemas. + +### Task 3: Implement List Sessions Handler + +**Files:** +- Modify: `lib/crates/fabro-server/src/web_auth.rs` + +- [x] Add response structs local to `web_auth.rs` that serialize to the OpenAPI response shape. +- [x] Register `GET /auth/sessions` in `api_routes()`. +- [x] Require an authenticated user with the same auth path as `auth_me`. +- [x] Read the current `SessionCookie` from headers and emit one browser session: + +```text +id: browser:current +kind: browser +current: true +provider: session_provider(session.auth_method) +login: session.login +label: This browser +userAgent: null +createdAt: session.iat as RFC3339 UTC +lastSeenAt: session.iat as RFC3339 UTC +expiresAt: session.exp as RFC3339 UTC +revocable: false +``` + +- [x] Query `RefreshTokenStore` for active CLI chains matching the authenticated identity. +- [x] Emit each CLI chain as: + +```text +id: cli: +kind: cli +current: false +provider: github +login: token.login +label: Fabro CLI +userAgent: token.user_agent +createdAt: token.issued_at +lastSeenAt: token.last_used_at +expiresAt: token.expires_at +revocable: true +``` + +- [x] Sort sessions with current sessions first, then descending `lastSeenAt`. + +### Task 4: Implement Revoke Session Handler + +**Files:** +- Modify: `lib/crates/fabro-server/src/web_auth.rs` + +- [x] Register `DELETE /auth/sessions/{id}` in `api_routes()`. +- [x] Require an authenticated user. +- [x] If `id == "browser:current"`, return a non-success JSON error because browser sessions are not durable in v1. +- [x] If `id` starts with `cli:`, parse the remainder as a UUID. +- [x] Look up active CLI chains for the authenticated identity and only delete the chain if it belongs to that identity. +- [x] Revoke valid CLI sessions with `RefreshTokenStore::delete_chain(chain_id)`. +- [x] Return `204 No Content` after successful revocation. +- [x] Return `404` for unknown session IDs and `400` for malformed IDs. + +### Task 5: Add Server Tests + +**Files:** +- Modify or add tests under `lib/crates/fabro-server/tests/it/api/` + +- [x] Add a test that authenticated browser requests receive one `browser` session. +- [x] Add a test that active CLI refresh-token chains for the same identity appear in the unified list. +- [x] Add a test that expired, used, and different-identity CLI tokens are excluded. +- [x] Add a test that deleting `cli:` removes that token chain. +- [x] Add a test that deleting `browser:current` returns a non-success response. +- [x] Add a test that unauthenticated list and delete requests return `401`. +- [x] Run: + +```bash +cargo nextest run -p fabro-server +``` + +Expected: `PASS`. + +## Final Validation + +Run: + +```bash +cargo build -p fabro-api +cargo nextest run -p fabro-store +cargo nextest run -p fabro-server +``` + +Expected: all commands pass. + +## Assumptions + +- V1 does not add durable browser-session storage. +- The current browser session is represented by the signed browser cookie only. +- CLI sessions are represented by active refresh-token chains. +- The API contract intentionally hides token hashes and raw refresh-token details. diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index f68b8a756..bfbebc067 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -1,11 +1,12 @@ use std::sync::Arc; use axum::extract::rejection::JsonRejection; -use axum::extract::{Query, State}; +use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::{Extension, Json, Router}; +use chrono::{DateTime, Utc}; use cookie::time::Duration; use cookie::{Cookie, CookieJar, Key, SameSite}; use fabro_redact::DisplaySafeUrl; @@ -19,6 +20,7 @@ use serde_json::json; use tracing::{debug, error, info, warn}; use crate::auth::{GithubEndpoints, browser_shell}; +use crate::error::ApiError; use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches}; use crate::principal_middleware::{ RequestAuth, RequestAuthContext, RequiredUser, UserProfile, require_authenticated_user, @@ -86,6 +88,27 @@ struct AuthMeResponse { demo_mode: bool, } +#[derive(Serialize)] +struct AuthSessionsResponse { + sessions: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AuthSession { + id: String, + kind: &'static str, + current: bool, + provider: String, + login: String, + label: String, + user_agent: Option, + created_at: DateTime, + last_seen_at: DateTime, + expires_at: DateTime, + revocable: bool, +} + #[derive(Serialize)] struct SessionUser { login: String, @@ -133,6 +156,8 @@ pub fn api_routes() -> Router> { Router::new() .route("/auth/config", get(auth_config)) .route("/auth/me", get(auth_me)) + .route("/auth/sessions", get(list_auth_sessions)) + .route("/auth/sessions/{id}", delete(delete_auth_session)) .route("/demo/toggle", post(toggle_demo)) } @@ -317,6 +342,15 @@ fn redacted_url_for_log(url: &str) -> String { .map_or_else(|_| "".to_string(), |url| url.redacted_string()) } +fn session_timestamp(timestamp: i64) -> Result, ApiError> { + DateTime::from_timestamp(timestamp, 0).ok_or_else(|| { + ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Authenticated session timestamp is out of range.", + ) + }) +} + #[expect( clippy::disallowed_methods, reason = "Web auth resolves configured {{ env.* }} URLs through this process-env facade." @@ -851,6 +885,160 @@ async fn auth_me(RequestAuth(auth_slot): RequestAuth, headers: HeaderMap) -> Res .into_response() } +async fn list_auth_sessions( + State(state): State>, + RequestAuth(auth_slot): RequestAuth, + headers: HeaderMap, +) -> Response { + let authenticated = match require_authenticated_user(&auth_slot) { + Ok(authenticated) => authenticated, + Err(err) => return err.into_response(), + }; + let now = Utc::now(); + let mut sessions = Vec::new(); + + if let Some(key) = state.session_key() { + if let Some(session) = read_private_session(&headers, &key) { + let issued_at = match session_timestamp(session.iat) { + Ok(timestamp) => timestamp, + Err(err) => return err.into_response(), + }; + let expires_at = match session_timestamp(session.exp) { + Ok(timestamp) => timestamp, + Err(err) => return err.into_response(), + }; + sessions.push(AuthSession { + id: "browser:current".to_string(), + kind: "browser", + current: true, + provider: session_provider(session.auth_method).to_string(), + login: session.login, + label: "This browser".to_string(), + user_agent: None, + created_at: issued_at, + last_seen_at: issued_at, + expires_at, + revocable: false, + }); + } + } + + let auth_tokens = match state.store_ref().refresh_tokens().await { + Ok(store) => store, + Err(err) => { + error!(error = %err, "Failed to open refresh token store while listing auth sessions"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to list auth sessions.", + ) + .into_response(); + } + }; + let cli_sessions = match auth_tokens + .active_cli_sessions(&authenticated.principal.identity, now) + .await + { + Ok(tokens) => tokens, + Err(err) => { + error!(error = %err, "Failed to scan refresh tokens while listing auth sessions"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to list auth sessions.", + ) + .into_response(); + } + }; + + sessions.extend(cli_sessions.into_iter().map(|token| AuthSession { + id: format!("cli:{}", token.chain_id), + kind: "cli", + current: false, + provider: "github".to_string(), + login: token.login, + label: "Fabro CLI".to_string(), + user_agent: Some(token.user_agent), + created_at: token.issued_at, + last_seen_at: token.last_used_at, + expires_at: token.expires_at, + revocable: true, + })); + sessions.sort_by(|left, right| { + right + .current + .cmp(&left.current) + .then_with(|| right.last_seen_at.cmp(&left.last_seen_at)) + }); + + Json(AuthSessionsResponse { sessions }).into_response() +} + +async fn delete_auth_session( + State(state): State>, + RequestAuth(auth_slot): RequestAuth, + Path(id): Path, +) -> Response { + let authenticated = match require_authenticated_user(&auth_slot) { + Ok(authenticated) => authenticated, + Err(err) => return err.into_response(), + }; + + if id == "browser:current" { + return ApiError::bad_request("Browser sessions cannot be revoked by this API version.") + .into_response(); + } + + let Some(raw_chain_id) = id.strip_prefix("cli:") else { + return ApiError::not_found("Auth session not found.").into_response(); + }; + let Ok(chain_id) = uuid::Uuid::parse_str(raw_chain_id) else { + return ApiError::bad_request("Malformed CLI auth session id.").into_response(); + }; + + let auth_tokens = match state.store_ref().refresh_tokens().await { + Ok(store) => store, + Err(err) => { + error!(error = %err, "Failed to open refresh token store while deleting auth session"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to revoke auth session.", + ) + .into_response(); + } + }; + let active_sessions = match auth_tokens + .active_cli_sessions(&authenticated.principal.identity, Utc::now()) + .await + { + Ok(tokens) => tokens, + Err(err) => { + error!(error = %err, "Failed to scan refresh tokens while deleting auth session"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to revoke auth session.", + ) + .into_response(); + } + }; + + if !active_sessions + .iter() + .any(|token| token.chain_id == chain_id) + { + return ApiError::not_found("Auth session not found.").into_response(); + } + + if let Err(err) = auth_tokens.delete_chain(chain_id).await { + error!(error = %err, %chain_id, "Failed to delete refresh token chain"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to revoke auth session.", + ) + .into_response(); + } + + StatusCode::NO_CONTENT.into_response() +} + async fn toggle_demo( _auth: RequiredUser, State(state): State>, diff --git a/lib/crates/fabro-server/tests/it/api/auth_sessions.rs b/lib/crates/fabro-server/tests/it/api/auth_sessions.rs new file mode 100644 index 000000000..230d3ebc4 --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/auth_sessions.rs @@ -0,0 +1,403 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use cookie::{Cookie, CookieJar, Key}; +use fabro_server::ip_allowlist::IpAllowlistConfig; +use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; +use fabro_server::server::{RouterOptions, build_router_with_options}; +use fabro_server::test_support::{TEST_SESSION_SECRET, TestAppStateBuilder}; +use fabro_server::web_auth::{SESSION_COOKIE_NAME, SessionCookie}; +use fabro_store::{ArtifactStore, Database, RefreshToken}; +use hkdf::Hkdf; +use object_store::memory::InMemory; +use sha2::Sha256; +use tower::ServiceExt; +use uuid::Uuid; + +use crate::helpers::{response_json, response_status, settings_from_toml}; + +fn test_app(source: &str) -> (axum::Router, Arc) { + let settings = settings_from_toml(source); + let object_store: Arc = Arc::new(InMemory::new()); + let store = Arc::new(Database::new( + Arc::clone(&object_store), + "", + Duration::from_millis(1), + None, + )); + let artifact_store = ArtifactStore::new(object_store, "artifacts"); + let auth_mode = + resolve_auth_mode_with_lookup(&settings.server_settings.server, |name| match name { + "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()), + "GITHUB_APP_CLIENT_SECRET" => Some("test-client-secret".to_string()), + _ => None, + }) + .expect("auth mode should resolve"); + let state = TestAppStateBuilder::new() + .runtime_settings(settings.server_settings, settings.manifest_run_defaults) + .max_concurrent_runs(5) + .store_bundle(Arc::clone(&store), artifact_store) + .server_secret_env(HashMap::from([( + "SESSION_SECRET".to_string(), + TEST_SESSION_SECRET.to_string(), + )])) + .build(); + let app = build_router_with_options( + state, + &auth_mode, + Arc::new(IpAllowlistConfig::default()), + RouterOptions::default(), + ); + (app, store) +} + +fn github_app() -> (axum::Router, Arc) { + test_app( + r#" +_version = 1 + +[server.auth] +methods = ["github"] + +[server.auth.github] +allowed_usernames = ["octocat"] + +[server.web] +url = "https://fabro.example" + +[server.integrations.github] +client_id = "Iv1.test" +"#, + ) +} + +fn github_identity() -> fabro_types::IdpIdentity { + fabro_types::IdpIdentity::new("https://github.com", "12345") + .expect("test GitHub identity should be valid") +} + +fn other_identity() -> fabro_types::IdpIdentity { + fabro_types::IdpIdentity::new("https://github.com", "67890") + .expect("test alternate GitHub identity should be valid") +} + +fn derive_cookie_key(master: &[u8]) -> Key { + let hkdf = Hkdf::::new(None, master); + let mut output = [0_u8; 64]; + hkdf.expand(b"fabro-cookie-v1", &mut output) + .expect("fixed-size HKDF output should be valid"); + Key::from(&output) +} + +fn session_cookie() -> String { + let now = chrono::Utc::now(); + let session = SessionCookie { + v: 2, + login: "octocat".to_string(), + auth_method: fabro_types::AuthMethod::Github, + identity: github_identity(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + avatar_url: "https://avatars.githubusercontent.com/u/583231".to_string(), + user_url: "https://github.com/octocat".to_string(), + iat: now.timestamp(), + exp: (now + chrono::Duration::days(30)).timestamp(), + }; + let key = derive_cookie_key(TEST_SESSION_SECRET.as_bytes()); + let mut jar = CookieJar::new(); + jar.private_mut(&key).add( + Cookie::build(( + SESSION_COOKIE_NAME, + serde_json::to_string(&session).expect("session should serialize"), + )) + .path("/") + .http_only(true) + .build(), + ); + jar.delta() + .next() + .expect("session cookie should be set") + .encoded() + .to_string() +} + +fn refresh_token(hash: [u8; 32], chain_id: Uuid) -> RefreshToken { + let now = chrono::Utc::now(); + RefreshToken { + token_hash: hash, + chain_id, + identity: github_identity(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + issued_at: now - chrono::Duration::days(1), + expires_at: now + chrono::Duration::days(30), + last_used_at: now, + used: false, + user_agent: "fabro-cli/it".to_string(), + } +} + +async fn get_sessions(app: axum::Router, cookie: &str) -> serde_json::Value { + response_json( + app.oneshot( + Request::builder() + .uri("/api/v1/auth/sessions") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .expect("GET auth sessions request should build"), + ) + .await + .expect("GET auth sessions should respond"), + StatusCode::OK, + "GET /api/v1/auth/sessions", + ) + .await +} + +#[tokio::test] +async fn authenticated_browser_requests_receive_current_browser_session() { + let (app, _store) = github_app(); + let body = get_sessions(app, &session_cookie()).await; + + let sessions = body["sessions"] + .as_array() + .expect("sessions should be an array"); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0]["id"], "browser:current"); + assert_eq!(sessions[0]["kind"], "browser"); + assert_eq!(sessions[0]["current"], true); + assert_eq!(sessions[0]["provider"], "github"); + assert_eq!(sessions[0]["login"], "octocat"); + assert_eq!(sessions[0]["label"], "This browser"); + assert_eq!(sessions[0]["userAgent"], serde_json::Value::Null); + assert_eq!(sessions[0]["revocable"], false); +} + +#[tokio::test] +async fn active_cli_refresh_token_chains_for_identity_appear_in_unified_list() { + let (app, store) = github_app(); + let auth_tokens = store + .refresh_tokens() + .await + .expect("refresh token store should open"); + let chain_id = Uuid::new_v4(); + auth_tokens + .insert_refresh_token(refresh_token([1_u8; 32], chain_id)) + .await + .expect("refresh token should insert"); + + let body = get_sessions(app, &session_cookie()).await; + let sessions = body["sessions"] + .as_array() + .expect("sessions should be an array"); + + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0]["id"], "browser:current"); + let cli = sessions + .iter() + .find(|session| session["id"] == format!("cli:{chain_id}")) + .expect("CLI session should be present"); + assert_eq!(cli["kind"], "cli"); + assert_eq!(cli["current"], false); + assert_eq!(cli["provider"], "github"); + assert_eq!(cli["login"], "octocat"); + assert_eq!(cli["label"], "Fabro CLI"); + assert_eq!(cli["userAgent"], "fabro-cli/it"); + assert_eq!(cli["revocable"], true); +} + +#[tokio::test] +async fn inactive_and_other_identity_cli_tokens_are_excluded() { + let (app, store) = github_app(); + let auth_tokens = store + .refresh_tokens() + .await + .expect("refresh token store should open"); + let active_chain_id = Uuid::new_v4(); + let now = chrono::Utc::now(); + let active = refresh_token([1_u8; 32], active_chain_id); + let mut expired = refresh_token([2_u8; 32], Uuid::new_v4()); + expired.expires_at = now - chrono::Duration::seconds(1); + let mut used = refresh_token([3_u8; 32], Uuid::new_v4()); + used.used = true; + let mut other = refresh_token([4_u8; 32], Uuid::new_v4()); + other.identity = other_identity(); + + for token in [active, expired, used, other] { + auth_tokens + .insert_refresh_token(token) + .await + .expect("refresh token should insert"); + } + + let body = get_sessions(app, &session_cookie()).await; + let session_ids = body["sessions"] + .as_array() + .expect("sessions should be an array") + .iter() + .map(|session| { + session["id"] + .as_str() + .expect("session id should be a string") + .to_string() + }) + .collect::>(); + + assert_eq!(session_ids, vec![ + "browser:current".to_string(), + format!("cli:{active_chain_id}") + ]); +} + +#[tokio::test] +async fn deleting_cli_session_removes_refresh_token_chain() { + let (app, store) = github_app(); + let auth_tokens = store + .refresh_tokens() + .await + .expect("refresh token store should open"); + let chain_id = Uuid::new_v4(); + let active = refresh_token([1_u8; 32], chain_id); + let mut used = refresh_token([2_u8; 32], chain_id); + used.used = true; + auth_tokens + .insert_refresh_token(active) + .await + .expect("active refresh token should insert"); + auth_tokens + .insert_refresh_token(used) + .await + .expect("used refresh token should insert"); + + response_status( + app.oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/v1/auth/sessions/cli:{chain_id}")) + .header(header::COOKIE, session_cookie()) + .body(Body::empty()) + .expect("DELETE CLI auth session request should build"), + ) + .await + .expect("DELETE CLI auth session should respond"), + StatusCode::NO_CONTENT, + "DELETE /api/v1/auth/sessions/{id}", + ) + .await; + + assert!( + auth_tokens + .find_refresh_token(&[1_u8; 32]) + .await + .expect("active token lookup should succeed") + .is_none() + ); + assert!( + auth_tokens + .find_refresh_token(&[2_u8; 32]) + .await + .expect("used token lookup should succeed") + .is_none() + ); +} + +#[tokio::test] +async fn deleting_current_browser_session_is_rejected() { + let (app, _store) = github_app(); + + let body = response_json( + app.oneshot( + Request::builder() + .method("DELETE") + .uri("/api/v1/auth/sessions/browser:current") + .header(header::COOKIE, session_cookie()) + .body(Body::empty()) + .expect("DELETE browser auth session request should build"), + ) + .await + .expect("DELETE browser auth session should respond"), + StatusCode::BAD_REQUEST, + "DELETE /api/v1/auth/sessions/browser:current", + ) + .await; + + assert_eq!(body["errors"][0]["status"], "400"); +} + +#[tokio::test] +async fn deleting_malformed_and_unknown_session_ids_returns_contract_errors() { + let (app, _store) = github_app(); + let cookie = session_cookie(); + + response_status( + app.clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/v1/auth/sessions/cli:not-a-uuid") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .expect("malformed DELETE request should build"), + ) + .await + .expect("malformed DELETE should respond"), + StatusCode::BAD_REQUEST, + "DELETE /api/v1/auth/sessions/cli:not-a-uuid", + ) + .await; + + response_status( + app.oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/v1/auth/sessions/cli:{}", Uuid::new_v4())) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .expect("unknown DELETE request should build"), + ) + .await + .expect("unknown DELETE should respond"), + StatusCode::NOT_FOUND, + "DELETE /api/v1/auth/sessions/cli:{unknown}", + ) + .await; +} + +#[tokio::test] +async fn unauthenticated_session_requests_return_unauthorized() { + let (app, _store) = github_app(); + + response_status( + app.clone() + .oneshot( + Request::builder() + .uri("/api/v1/auth/sessions") + .body(Body::empty()) + .expect("unauthenticated GET request should build"), + ) + .await + .expect("unauthenticated GET should respond"), + StatusCode::UNAUTHORIZED, + "GET /api/v1/auth/sessions", + ) + .await; + + response_status( + app.oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/v1/auth/sessions/cli:{}", Uuid::new_v4())) + .body(Body::empty()) + .expect("unauthenticated DELETE request should build"), + ) + .await + .expect("unauthenticated DELETE should respond"), + StatusCode::UNAUTHORIZED, + "DELETE /api/v1/auth/sessions/{id}", + ) + .await; +} diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs index 01e8f3f80..9ec22972a 100644 --- a/lib/crates/fabro-server/tests/it/api/mod.rs +++ b/lib/crates/fabro-server/tests/it/api/mod.rs @@ -1,3 +1,4 @@ +mod auth_sessions; mod cli_auth_token; mod docs; mod install; diff --git a/lib/crates/fabro-store/src/slate/auth_tokens.rs b/lib/crates/fabro-store/src/slate/auth_tokens.rs index 97cb1a8aa..8ed2d79b8 100644 --- a/lib/crates/fabro-store/src/slate/auth_tokens.rs +++ b/lib/crates/fabro-store/src/slate/auth_tokens.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; use dashmap::DashMap; use fabro_types::IdpIdentity; +use futures::StreamExt; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -78,6 +79,33 @@ impl RefreshTokenStore { self.repo.get(token_hash).await } + pub async fn active_cli_sessions( + &self, + identity: &IdpIdentity, + now: DateTime, + ) -> Result> { + let mut active_by_chain = std::collections::HashMap::::new(); + let mut tokens = self.repo.scan_stream(); + + while let Some(result) = tokens.next().await { + let (_, token) = result?; + if token.identity != *identity || token.used || token.expires_at <= now { + continue; + } + + active_by_chain + .entry(token.chain_id) + .and_modify(|current| { + if token.last_used_at > current.last_used_at { + *current = token.clone(); + } + }) + .or_insert(token); + } + + Ok(active_by_chain.into_values().collect()) + } + pub async fn consume_and_rotate( &self, presented_hash: [u8; 32], @@ -175,6 +203,10 @@ mod tests { } } + fn alternate_identity() -> fabro_types::IdpIdentity { + fabro_types::IdpIdentity::new("https://github.com", "67890").unwrap() + } + #[tokio::test] async fn insert_find_rotate_and_reuse_work() { let store = store().await; @@ -333,6 +365,60 @@ mod tests { ); } + #[tokio::test] + async fn active_cli_sessions_return_newest_active_token_per_chain_for_identity() { + let store = store().await; + let identity = fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(); + let now = chrono::Utc::now(); + let duplicate_chain_id = Uuid::new_v4(); + let other_chain_id = Uuid::new_v4(); + + let mut old_duplicate = refresh_token([1_u8; 32], duplicate_chain_id, false); + old_duplicate.last_used_at = now - ChronoDuration::minutes(10); + old_duplicate.issued_at = now - ChronoDuration::minutes(20); + let mut newest_duplicate = refresh_token([2_u8; 32], duplicate_chain_id, false); + newest_duplicate.last_used_at = now - ChronoDuration::minutes(1); + newest_duplicate.issued_at = now - ChronoDuration::minutes(15); + let mut other_active = refresh_token([3_u8; 32], other_chain_id, false); + other_active.last_used_at = now - ChronoDuration::minutes(3); + + store.insert_refresh_token(old_duplicate).await.unwrap(); + store + .insert_refresh_token(newest_duplicate.clone()) + .await + .unwrap(); + store + .insert_refresh_token(other_active.clone()) + .await + .unwrap(); + + let sessions = store.active_cli_sessions(&identity, now).await.unwrap(); + assert_eq!(sessions.len(), 2); + assert!(sessions.contains(&newest_duplicate)); + assert!(sessions.contains(&other_active)); + } + + #[tokio::test] + async fn active_cli_sessions_exclude_expired_used_and_other_identity_tokens() { + let store = store().await; + let identity = fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(); + let now = chrono::Utc::now(); + + let active = refresh_token([1_u8; 32], Uuid::new_v4(), false); + let mut expired = refresh_token([2_u8; 32], Uuid::new_v4(), false); + expired.expires_at = now - ChronoDuration::seconds(1); + let used = refresh_token([3_u8; 32], Uuid::new_v4(), true); + let mut other_identity = refresh_token([4_u8; 32], Uuid::new_v4(), false); + other_identity.identity = alternate_identity(); + + for token in [active.clone(), expired, used, other_identity] { + store.insert_refresh_token(token).await.unwrap(); + } + + let sessions = store.active_cli_sessions(&identity, now).await.unwrap(); + assert_eq!(sessions, vec![active]); + } + #[tokio::test] async fn gc_expired_removes_only_tokens_at_or_before_cutoff() { let store = store().await;