mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
acec32cea9
commit
b6d4d240ae
6 changed files with 1012 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
213
docs/superpowers/plans/2026-05-10-auth-sessions-backend.md
Normal file
213
docs/superpowers/plans/2026-05-10-auth-sessions-backend.md
Normal 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.
|
||||
|
|
@ -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>>,
|
||||
|
|
|
|||
403
lib/crates/fabro-server/tests/it/api/auth_sessions.rs
Normal file
403
lib/crates/fabro-server/tests/it/api/auth_sessions.rs
Normal 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;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod auth_sessions;
|
||||
mod cli_auth_token;
|
||||
mod docs;
|
||||
mod install;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue