diff --git a/src/oauth/callback.rs b/src/oauth/callback.rs index badeeda..390b6f9 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -137,9 +137,7 @@ pub async fn callback( private_signing_key_data: signing_key, }; - // 0.14.5's `OAuthRequest` has no `subject` field — the crate doesn't bind - // the callback to an expected DID for us. We verify the resulting token's - // `sub` against `persisted.subject` ourselves below. + // Verify the resulting token's`sub` against `persisted.subject`. let oauth_request = OAuthRequest { oauth_state: persisted.state.clone(), issuer: persisted.issuer.clone(), @@ -164,11 +162,7 @@ pub async fn callback( .await .map_err(|e| AppError::Internal(anyhow::anyhow!("token exchange failed: {e}")))?; - // Subject-binding check: `oauth_complete` does not validate `sub` against - // anything for us. Never trust a token-supplied subject that contradicts - // the DID we resolved+pinned before sending the user off to authorize — - // that's how a hostile/compromised authorization server could mint a - // session as someone else. + // Subject-binding check. let did = match (&persisted.subject, &token_response.sub) { (Some(expected), Some(sub)) if expected == sub => sub.clone(), (Some(expected), Some(sub)) => { diff --git a/src/oauth/logout.rs b/src/oauth/logout.rs new file mode 100644 index 0000000..83acc07 --- /dev/null +++ b/src/oauth/logout.rs @@ -0,0 +1,34 @@ +//! OAuth logout: clear both the session and identity cookies. + +use axum::extract::State; +use axum::http::{header, HeaderMap}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::post; +use axum::Router; + +use crate::error::AppError; +use crate::oauth::session::{build_clear_cookie_header, IDENTITY_COOKIE_NAME, SESSION_COOKIE_NAME}; +use crate::state::AppState; + +/// `POST /auth/logout` -- clear both cookies and redirect home. +/// +/// Local only: `atproto_oauth` 0.14.5 has no token-revocation call, so +/// tokens stay valid server-side until they expire. +pub async fn logout(State(state): State) -> Result { + let external_base = &state.config.external_base; + let mut headers = HeaderMap::new(); + // Surface (not swallow) a failure to clear cookies, or logout could + // silently not log the user out. + let clear_session = build_clear_cookie_header(SESSION_COOKIE_NAME, external_base, true) + .map_err(|e| AppError::Internal(anyhow::anyhow!("cookie clear failed: {e}")))?; + let clear_identity = build_clear_cookie_header(IDENTITY_COOKIE_NAME, external_base, false) + .map_err(|e| AppError::Internal(anyhow::anyhow!("cookie clear failed: {e}")))?; + headers.append(header::SET_COOKIE, clear_session); + headers.append(header::SET_COOKIE, clear_identity); + Ok((headers, Redirect::to("/")).into_response()) +} + +/// Routes for logout. +pub fn router() -> axum::Router { + Router::new().route("/auth/logout", post(logout)) +} diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index 33a4b82..e7bea43 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -3,7 +3,9 @@ pub mod callback; pub mod login; +pub mod logout; pub mod metadata; +pub mod refresh; pub mod session; use axum::routing::get; @@ -18,4 +20,6 @@ pub fn router() -> Router { .route("/jwks.json", get(metadata::jwks)) .merge(login::router()) .merge(callback::router()) + .merge(refresh::router()) + .merge(logout::router()) } diff --git a/src/oauth/refresh.rs b/src/oauth/refresh.rs new file mode 100644 index 0000000..fe2909b --- /dev/null +++ b/src/oauth/refresh.rs @@ -0,0 +1,119 @@ +//! Re-mints the session cookie when the access token is near expiry. + +use atproto_identity::key::identify_key; +use atproto_oauth::workflow::{oauth_refresh, OAuthClient}; +use axum::extract::State; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::Router; +use chrono::{Duration, Utc}; + +use crate::error::AppError; +use crate::oauth::session::{ + build_session_cookie_header, encode_session_cookie, get_session_from_headers, SessionCookie, +}; +use crate::state::AppState; + +/// `POST /auth/refresh` -- refresh the session cookie if it's near expiry. +pub async fn refresh( + State(state): State, + headers: HeaderMap, +) -> Result { + let session = get_session_from_headers(&state.config.cookie_secret, &headers) + .ok_or(AppError::Unauthorized)?; + + let (_session, set_cookie) = try_refresh_session(&state, session).await?; + + match set_cookie { + Some(value) => { + let header_value = axum::http::HeaderValue::from_str(&value) + .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid cookie value: {e}")))?; + let mut out = HeaderMap::new(); + out.append(header::SET_COOKIE, header_value); + Ok((out, StatusCode::NO_CONTENT).into_response()) + } + None => Ok(StatusCode::NO_CONTENT.into_response()), + } +} + +/// Refresh `session` in place if it's within 5 minutes of expiry, returning +/// the (possibly updated) session and an optional `Set-Cookie` header value +/// for the caller to attach. +/// +/// `pub` for future callers, e.g. an auto-refresh check in protected +/// mod-CRUD handlers. +pub async fn try_refresh_session( + state: &AppState, + session: SessionCookie, +) -> Result<(SessionCookie, Option), AppError> { + if !session.expires_within(Duration::minutes(5)) { + return Ok((session, None)); + } + + let Some(refresh_token) = session.refresh_token.clone() else { + return Ok((session, None)); + }; + + let signing_key = state + .config + .oauth_private_keys + .first() + .cloned() + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("no OAuth signing key configured")))?; + + let dpop_key = identify_key(&session.dpop_private_key) + .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid stored DPoP key: {e}")))?; + + let document = state + .identity_resolver + .resolve(&session.did) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("identity resolution failed: {e}")))?; + + let oauth_client = OAuthClient { + redirect_uri: state.config.oauth_redirect_uri(), + client_id: state.config.oauth_client_id(), + private_signing_key_data: signing_key, + }; + + let token_response = oauth_refresh( + &state.http_client, + &oauth_client, + &dpop_key, + &refresh_token, + &document, + ) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("token refresh failed: {e}")))?; + + let expires_at = Utc::now() + Duration::seconds(i64::from(token_response.expires_in)); + + let new_session = SessionCookie { + did: session.did.clone(), + access_token: token_response.access_token.clone(), + refresh_token: token_response + .refresh_token + .clone() + .or(session.refresh_token.clone()), + expires_at, + dpop_private_key: session.dpop_private_key.clone(), + }; + + let encoded = encode_session_cookie(&state.config.cookie_secret, &new_session) + .map_err(|e| AppError::Internal(anyhow::anyhow!("session encode failed: {e}")))?; + + let max_age = Duration::days(30).num_seconds(); + let set_cookie = build_session_cookie_header(&state.config.external_base, &encoded, max_age) + .map_err(|e| AppError::Internal(anyhow::anyhow!("cookie build failed: {e}")))? + .to_str() + .map_err(|e| AppError::Internal(anyhow::anyhow!("cookie to_str failed: {e}")))? + .to_string(); + + Ok((new_session, Some(set_cookie))) +} + +/// Routes for session refresh. +pub fn router() -> Router { + Router::new().route("/auth/refresh", post(refresh)) +}