Skip to content

Commit 6efb813

Browse files
fix: logout (#45)
Co-authored-by: Pierre Le Guen <26087574+PierreLeGuen@users.noreply.github.com>
1 parent 6f757dc commit 6efb813

File tree

6 files changed

+120
-16
lines changed

6 files changed

+120
-16
lines changed

crates/api/src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ impl ApiError {
111111
.with_details("Session token must start with 'sess_' and be 37 characters long")
112112
}
113113

114+
/// Session id not found
115+
pub fn session_id_not_found() -> Self {
116+
Self::not_found("Session id not found")
117+
.with_details("The provided session id does not match any active session.")
118+
}
119+
114120
/// Session token not found
115121
pub fn session_not_found() -> Self {
116122
Self::unauthorized("Session not found").with_details(

crates/api/src/openapi.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ use utoipa::OpenApi;
3838
crate::models::UserProfileResponse,
3939
crate::models::AuthResponse,
4040
crate::error::ApiErrorResponse,
41+
// Auth request models
42+
crate::routes::oauth::LogoutRequest,
4143
// User settings models
4244
crate::models::UserSettingsResponse,
4345
crate::models::UpdateUserSettingsPartiallyRequest,

crates/api/src/routes/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ pub fn create_router_with_cors(app_state: AppState, allowed_origins: Vec<String>
5555
// OAuth routes (public, no auth required)
5656
let auth_routes = oauth::create_oauth_router();
5757

58+
// Logout route (requires authentication)
59+
let logout_route = Router::new()
60+
.route("/logout", axum::routing::post(oauth::logout))
61+
.layer(from_fn_with_state(
62+
auth_state.clone(),
63+
crate::middleware::auth_middleware,
64+
));
65+
5866
// Attestation routes (public, no auth required)
5967
let attestation_routes = attestation::create_attestation_router();
6068

@@ -80,6 +88,7 @@ pub fn create_router_with_cors(app_state: AppState, allowed_origins: Vec<String>
8088
let router = Router::new()
8189
.route("/health", get(health_check))
8290
.nest("/v1/auth", auth_routes)
91+
.nest("/v1/auth", logout_route) // Logout route with auth middleware
8392
.nest("/v1/users", user_routes)
8493
.nest("/v1/admin", admin_routes)
8594
.merge(api_routes) // Merge instead of nest since api routes already have /v1 prefix

crates/api/src/routes/oauth.rs

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
use crate::{error::ApiError, state::AppState};
1+
use crate::{error::ApiError, middleware::AuthenticatedUser, state::AppState};
2+
use axum::extract::Query;
23
use axum::{
3-
extract::{Query, State},
4+
extract::{Extension, State},
45
http::StatusCode,
56
response::Redirect,
67
routing::get,
7-
Router,
8+
Json, Router,
89
};
9-
use serde::Deserialize;
10+
use serde::{Deserialize, Serialize};
1011
use services::SessionId;
12+
use utoipa::ToSchema;
1113

1214
/// Query parameters for OAuth callback
1315
#[derive(Debug, Deserialize)]
@@ -23,6 +25,13 @@ pub struct OAuthInitQuery {
2325
pub frontend_callback: Option<String>,
2426
}
2527

28+
/// Request body for logout
29+
#[derive(Debug, Deserialize, Serialize, ToSchema)]
30+
pub struct LogoutRequest {
31+
/// Session ID to revoke
32+
pub session_id: SessionId,
33+
}
34+
2635
/// Request body for mock login (test only)
2736
#[cfg(feature = "test")]
2837
#[derive(Debug, Deserialize)]
@@ -148,9 +157,10 @@ pub async fn oauth_callback(
148157
tracing::info!("Redirecting to frontend: {}", frontend_url);
149158

150159
let mut callback_url = format!(
151-
"{}/auth/callback?token={}&expires_at={}",
160+
"{}/auth/callback?token={}&session_id={}&expires_at={}",
152161
frontend_url,
153162
urlencoding::encode(&token),
163+
urlencoding::encode(&session.session_id.to_string()),
154164
urlencoding::encode(&session.expires_at.to_rfc3339())
155165
);
156166
if is_new_user {
@@ -214,14 +224,15 @@ pub async fn github_login(
214224

215225
/// Handler for logout
216226
#[utoipa::path(
217-
get,
227+
post,
218228
path = "/v1/auth/logout",
219229
tag = "Auth",
220-
params(
221-
("session_id" = uuid::Uuid, Query, description = "Session ID to revoke")
222-
),
230+
request_body = LogoutRequest,
223231
responses(
224232
(status = 204, description = "Successfully logged out"),
233+
(status = 401, description = "Unauthorized", body = crate::error::ApiErrorResponse),
234+
(status = 403, description = "Forbidden - session does not belong to authenticated user", body = crate::error::ApiErrorResponse),
235+
(status = 404, description = "Session not found", body = crate::error::ApiErrorResponse),
225236
(status = 500, description = "Internal server error", body = crate::error::ApiErrorResponse)
226237
),
227238
security(
@@ -230,9 +241,41 @@ pub async fn github_login(
230241
)]
231242
pub async fn logout(
232243
State(app_state): State<AppState>,
233-
Query(session_id): Query<SessionId>,
244+
Extension(authenticated_user): Extension<AuthenticatedUser>,
245+
Json(request): Json<LogoutRequest>,
234246
) -> Result<StatusCode, ApiError> {
235-
tracing::info!("Logout requested for session_id: {}", session_id);
247+
let session_id = request.session_id;
248+
tracing::info!(
249+
"Logout requested for session_id: {} by user_id: {}",
250+
session_id,
251+
authenticated_user.user_id
252+
);
253+
254+
// Verify that the session belongs to the authenticated user
255+
let session = app_state
256+
.session_repository
257+
.get_session_by_id(session_id)
258+
.await
259+
.map_err(|e| {
260+
tracing::error!("Failed to get session {}: {}", session_id, e);
261+
ApiError::logout_failed()
262+
})?;
263+
264+
let session = session.ok_or_else(|| {
265+
tracing::warn!("Session {} not found", session_id);
266+
ApiError::session_id_not_found()
267+
})?;
268+
269+
// Verify that the session belongs to the authenticated user
270+
if session.user_id != authenticated_user.user_id {
271+
tracing::warn!(
272+
"User {} attempted to logout session {} which belongs to user {}",
273+
authenticated_user.user_id,
274+
session_id,
275+
session.user_id
276+
);
277+
return Err(ApiError::forbidden("You can only logout your own sessions"));
278+
}
236279

237280
app_state
238281
.oauth_service
@@ -243,7 +286,11 @@ pub async fn logout(
243286
ApiError::logout_failed()
244287
})?;
245288

246-
tracing::info!("Session {} successfully revoked", session_id);
289+
tracing::info!(
290+
"Session {} successfully revoked by user_id: {}",
291+
session_id,
292+
authenticated_user.user_id
293+
);
247294

248295
Ok(StatusCode::NO_CONTENT)
249296
}
@@ -317,16 +364,14 @@ pub async fn mock_login(
317364
}))
318365
}
319366

320-
/// Create OAuth router with all routes
367+
/// Create OAuth router with all routes (excluding logout, which requires auth)
321368
pub fn create_oauth_router() -> Router<AppState> {
322369
let router = Router::new()
323370
// OAuth initiation routes
324371
.route("/google", get(google_login))
325372
.route("/github", get(github_login))
326373
// Unified callback route for all providers
327-
.route("/callback", get(oauth_callback))
328-
// Logout route
329-
.route("/logout", get(logout));
374+
.route("/callback", get(oauth_callback));
330375

331376
// Add mock login route only in test builds
332377
#[cfg(feature = "test")]

crates/database/src/repositories/session_repository.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,44 @@ impl SessionRepository for PostgresSessionRepository {
117117
Ok(result)
118118
}
119119

120+
async fn get_session_by_id(
121+
&self,
122+
session_id: SessionId,
123+
) -> anyhow::Result<Option<UserSession>> {
124+
tracing::debug!("Looking up session by session_id: {}", session_id);
125+
126+
let client = self.pool.get().await?;
127+
128+
let row = client
129+
.query_opt(
130+
"SELECT id, user_id, created_at, expires_at
131+
FROM sessions
132+
WHERE id = $1",
133+
&[&session_id],
134+
)
135+
.await?;
136+
137+
let result = row.map(|r| UserSession {
138+
session_id: r.get(0),
139+
user_id: r.get(1),
140+
created_at: r.get(2),
141+
expires_at: r.get(3),
142+
token: None, // Never return the token on retrieval
143+
});
144+
145+
if let Some(ref session) = result {
146+
tracing::debug!(
147+
"Session found: session_id={}, user_id={}",
148+
session.session_id,
149+
session.user_id
150+
);
151+
} else {
152+
tracing::debug!("No session found for session_id: {}", session_id);
153+
}
154+
155+
Ok(result)
156+
}
157+
120158
async fn delete_session(&self, session_id: SessionId) -> anyhow::Result<()> {
121159
tracing::info!("Deleting session: session_id={}", session_id);
122160

crates/services/src/auth/ports.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ pub trait SessionRepository: Send + Sync {
5757
token_hash: String,
5858
) -> anyhow::Result<Option<UserSession>>;
5959

60+
/// Retrieve a session by session ID
61+
async fn get_session_by_id(&self, session_id: SessionId)
62+
-> anyhow::Result<Option<UserSession>>;
63+
6064
/// Delete a session
6165
async fn delete_session(&self, session_id: SessionId) -> anyhow::Result<()>;
6266
}

0 commit comments

Comments
 (0)