feat(auth): add unified session API

Expose browser and CLI auth sessions through a normalized API, and allow revoking active CLI refresh-token chains while keeping browser sessions non-revocable for v1.
This commit is contained in:
Bryan Helmkamp 2026-05-10 14:16:23 -04:00
parent acec32cea9
commit b6d4d240ae
No known key found for this signature in database
6 changed files with 1012 additions and 2 deletions

View file

@ -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

View file

@ -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:<chain_id>`
## 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:<chain_id>
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:<chain_id>` 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.

View file

@ -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<AuthSession>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AuthSession {
id: String,
kind: &'static str,
current: bool,
provider: String,
login: String,
label: String,
user_agent: Option<String>,
created_at: DateTime<Utc>,
last_seen_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
revocable: bool,
}
#[derive(Serialize)]
struct SessionUser {
login: String,
@ -133,6 +156,8 @@ pub fn api_routes() -> Router<Arc<AppState>> {
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(|_| "<invalid url>".to_string(), |url| url.redacted_string())
}
fn session_timestamp(timestamp: i64) -> Result<DateTime<Utc>, 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<Arc<AppState>>,
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<Arc<AppState>>,
RequestAuth(auth_slot): RequestAuth,
Path(id): Path<String>,
) -> 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<Arc<AppState>>,

View file

@ -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<Database>) {
let settings = settings_from_toml(source);
let object_store: Arc<dyn object_store::ObjectStore> = 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<Database>) {
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::<Sha256>::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::<Vec<_>>();
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;
}

View file

@ -1,3 +1,4 @@
mod auth_sessions;
mod cli_auth_token;
mod docs;
mod install;

View file

@ -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<Utc>,
) -> Result<Vec<RefreshToken>> {
let mut active_by_chain = std::collections::HashMap::<Uuid, RefreshToken>::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;