feat(auth): separate anonymous entry from expired session recovery
This commit is contained in:
@@ -91,11 +91,38 @@ pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||
cookie_header.split(';').find_map(|pair| {
|
||||
let (name, value) = pair.trim().split_once('=')?;
|
||||
(name == cookie_name && !value.is_empty()).then(|| value.to_string())
|
||||
})
|
||||
unique_cookie(headers, cookie_name)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
// Cookie has no path/domain metadata on requests. Duplicates (including identical values
|
||||
// and multiple Cookie headers) cannot be disambiguated safely after cookie tossing.
|
||||
fn unique_cookie(
|
||||
headers: &HeaderMap,
|
||||
cookie_name: &str,
|
||||
) -> std::result::Result<Option<String>, ()> {
|
||||
let mut found = None;
|
||||
for header in headers.get_all(header::COOKIE) {
|
||||
let header = header.to_str().map_err(|_| ())?;
|
||||
for pair in header.split(';') {
|
||||
let pair = pair.trim();
|
||||
let Some((name, value)) = pair.split_once('=') else {
|
||||
if pair == cookie_name {
|
||||
return Err(());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if name == cookie_name {
|
||||
if found.is_some() {
|
||||
return Err(());
|
||||
}
|
||||
found = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub async fn resolve_request_actor(
|
||||
@@ -121,6 +148,14 @@ pub(crate) fn resolve_request_actor_sync(
|
||||
bearer: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<Option<RequestActor>> {
|
||||
Ok(resolve_credentials_sync(store, bearer, session)?.map(|(actor, _)| actor))
|
||||
}
|
||||
|
||||
fn resolve_credentials_sync(
|
||||
store: &dyn ControlPlaneStore,
|
||||
bearer: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<Option<(RequestActor, Option<String>)>> {
|
||||
if let Some(token) = bearer {
|
||||
let token_hash = token_hash(&token);
|
||||
if let Some(api_token) = store.resolve_api_token(&token_hash)? {
|
||||
@@ -132,7 +167,10 @@ pub(crate) fn resolve_request_actor_sync(
|
||||
return Ok(None);
|
||||
}
|
||||
store.mark_api_token_used(&token_hash, &now_rfc3339())?;
|
||||
return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken);
|
||||
return Ok(
|
||||
actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken)?
|
||||
.map(|actor| (actor, None)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,13 +179,95 @@ pub(crate) fn resolve_request_actor_sync(
|
||||
if is_expired(&session.expires_at) {
|
||||
return Ok(None);
|
||||
}
|
||||
return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession);
|
||||
return Ok(
|
||||
actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession)?
|
||||
.map(|actor| (actor, Some(session.expires_at))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// This marker is a non-authenticating reference to the existing non-revoked session row.
|
||||
// It outlives the auth cookie so Max-Age expiry remains distinguishable from a new browser.
|
||||
// No second session store is introduced; clearing all cookies necessarily loses this evidence.
|
||||
// Keep the established 14-day authentication lifetime. The additional week is only a bounded
|
||||
// return-navigation window (including a week away); it never extends authentication. The store
|
||||
// reclaims expired rows past this same boundary on subsequent session issuance. Neither whoami
|
||||
// nor other reads refresh the marker or the row's absolute expiry.
|
||||
pub const BROWSER_SESSION_SECONDS: i64 = 14 * 24 * 60 * 60;
|
||||
pub const SESSION_EXPIRY_GRACE_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||
|
||||
pub fn session_marker_cookie_name(cookie_name: &str) -> String {
|
||||
format!("{cookie_name}_expiry")
|
||||
}
|
||||
|
||||
pub async fn resolve_whoami(
|
||||
store: std::sync::Arc<dyn ControlPlaneStore>,
|
||||
headers: &HeaderMap,
|
||||
cookie_name: &str,
|
||||
) -> Result<server_api::WhoamiResponse> {
|
||||
let bearer = parse_bearer(headers);
|
||||
// Even an empty/invalid auth cookie takes precedence over an older marker.
|
||||
// An ambiguous primary is invalid, not absent; never fall back to an older marker.
|
||||
let session = unique_cookie(headers, cookie_name).unwrap_or_else(|()| Some(String::new()));
|
||||
let marker = parse_cookie(headers, &session_marker_cookie_name(cookie_name));
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || resolve_whoami_sync(store.as_ref(), bearer, session, marker))
|
||||
.await
|
||||
}
|
||||
|
||||
fn resolve_whoami_sync(
|
||||
store: &dyn ControlPlaneStore,
|
||||
bearer: Option<String>,
|
||||
session_token: Option<String>,
|
||||
marker: Option<String>,
|
||||
) -> Result<server_api::WhoamiResponse> {
|
||||
use server_api::{BrowserSessionState, WhoamiResponse};
|
||||
let anonymous = || WhoamiResponse {
|
||||
actor: None,
|
||||
session: BrowserSessionState::Anonymous {},
|
||||
};
|
||||
// Preserve the existing API-token precedence and fallback rules, including expired tokens.
|
||||
if let Some((actor, expires_at)) =
|
||||
resolve_credentials_sync(store, bearer, session_token.clone())?
|
||||
{
|
||||
return Ok(WhoamiResponse {
|
||||
actor: Some(actor),
|
||||
session: BrowserSessionState::Authenticated { expires_at },
|
||||
});
|
||||
}
|
||||
let hash = match session_token {
|
||||
Some(token) => token_hash(&token),
|
||||
None => match marker {
|
||||
Some(hash) => hash,
|
||||
None => return Ok(anonymous()),
|
||||
},
|
||||
};
|
||||
let Some(session) = store.resolve_browser_session(&hash)? else {
|
||||
return Ok(anonymous());
|
||||
};
|
||||
if store.get_user(&session.user_id)?.is_none() {
|
||||
return Ok(anonymous());
|
||||
}
|
||||
if within_session_expiry_grace(&session.expires_at, Utc::now()) {
|
||||
return Ok(WhoamiResponse {
|
||||
actor: None,
|
||||
session: BrowserSessionState::Expired {},
|
||||
});
|
||||
}
|
||||
// A marker for a still-valid session cannot restore authentication after partial deletion.
|
||||
Ok(anonymous())
|
||||
}
|
||||
|
||||
fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool {
|
||||
DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| {
|
||||
expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS)
|
||||
})
|
||||
}
|
||||
|
||||
fn actor_for_user<S: ControlPlaneStore + ?Sized>(
|
||||
store: &S,
|
||||
user_id: &str,
|
||||
@@ -274,6 +394,13 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(actor.user_id, "user-auth");
|
||||
assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken);
|
||||
let whoami = resolve_whoami(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
whoami.session,
|
||||
server_api::BrowserSessionState::Authenticated { expires_at: None }
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.resolve_api_token(&hash)
|
||||
@@ -363,20 +490,119 @@ mod tests {
|
||||
assert!(futures::poll!(queued.as_mut()).is_pending());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap());
|
||||
let actor_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
resolve_request_actor(store.clone(), &headers, "session"),
|
||||
)
|
||||
.await;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
resolve_request_actor(store, &headers, "session"),
|
||||
resolve_whoami(store, &headers, "session"),
|
||||
)
|
||||
.await;
|
||||
release.send(()).unwrap();
|
||||
blocker.await.unwrap().unwrap();
|
||||
queued.await.unwrap();
|
||||
assert!(matches!(
|
||||
actor_result.expect("actor lookup rejects capacity"),
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
assert!(matches!(
|
||||
result.expect("capacity rejection must not wait for queued SQL"),
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn whoami_classifies_only_live_store_backed_session_expiry() {
|
||||
use server_api::BrowserSessionState;
|
||||
let store = seeded_store();
|
||||
for (secret, offset, revoked) in [
|
||||
("recent", -60, false),
|
||||
("old", -SESSION_EXPIRY_GRACE_SECONDS - 60, false),
|
||||
("revoked", -60, true),
|
||||
] {
|
||||
store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
session_id: format!("session-{secret}"),
|
||||
token_hash: token_hash(secret),
|
||||
user_id: "user-auth".into(),
|
||||
created_at: "1".into(),
|
||||
expires_at: rfc3339_after(Duration::seconds(offset)),
|
||||
revoked_at: revoked.then(|| now_rfc3339()),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let cases = [
|
||||
(String::new(), BrowserSessionState::Anonymous {}),
|
||||
("session=unknown".into(), BrowserSessionState::Anonymous {}),
|
||||
("session=recent".into(), BrowserSessionState::Expired {}),
|
||||
(
|
||||
format!("session_expiry={}", token_hash("recent")),
|
||||
BrowserSessionState::Expired {},
|
||||
),
|
||||
(
|
||||
format!("session_expiry={}", token_hash("session-secret")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
"session_expiry=unknown".into(),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
"session_expiry=recent".into(),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
format!("session=unknown; session_expiry={}", token_hash("recent")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
format!("session=; session_expiry={}", token_hash("recent")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
("session=revoked".into(), BrowserSessionState::Anonymous {}),
|
||||
(
|
||||
format!("session_expiry={}", token_hash("revoked")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
("session=old".into(), BrowserSessionState::Anonymous {}),
|
||||
(
|
||||
format!("session_expiry={}", token_hash("old")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"session=session-secret; session_expiry={}",
|
||||
token_hash("recent")
|
||||
),
|
||||
BrowserSessionState::Authenticated {
|
||||
expires_at: Some("9999-01-01T00:00:00Z".into()),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (cookie, expected) in cases {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, cookie.parse().unwrap());
|
||||
let response = resolve_whoami(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.session, expected, "cookie: {cookie}");
|
||||
assert_eq!(
|
||||
response.actor.is_some(),
|
||||
matches!(expected, BrowserSessionState::Authenticated { .. })
|
||||
);
|
||||
if !matches!(expected, BrowserSessionState::Authenticated { .. }) {
|
||||
assert!(
|
||||
resolve_request_actor(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_code_uses_the_uuid_random_tail() {
|
||||
let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID");
|
||||
@@ -395,4 +621,136 @@ mod tests {
|
||||
);
|
||||
assert_eq!(code, code.to_ascii_uppercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_grace_includes_expiry_but_excludes_its_absolute_end() {
|
||||
let expiry = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
for (offset, expected) in [
|
||||
(-1, false),
|
||||
(0, true),
|
||||
(SESSION_EXPIRY_GRACE_SECONDS - 1, true),
|
||||
(SESSION_EXPIRY_GRACE_SECONDS, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
within_session_expiry_grace(
|
||||
&expiry.to_rfc3339(),
|
||||
expiry + Duration::seconds(offset)
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert!(!within_session_expiry_grace("invalid", expiry));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_cookies_never_select_an_actor_or_promote_an_ambiguous_marker() {
|
||||
use server_api::BrowserSessionState;
|
||||
let store = seeded_store();
|
||||
let expired_hash = token_hash("expired-duplicate");
|
||||
store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
session_id: "expired-duplicate".into(),
|
||||
token_hash: expired_hash.clone(),
|
||||
user_id: "user-auth".into(),
|
||||
created_at: now_rfc3339(),
|
||||
expires_at: rfc3339_after(Duration::seconds(-60)),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
for cookies in [
|
||||
vec!["session=session-secret; session=unknown".to_owned()],
|
||||
vec!["session=session-secret; session=session-secret".to_owned()],
|
||||
vec![
|
||||
"session=session-secret".to_owned(),
|
||||
"session=unknown".to_owned(),
|
||||
],
|
||||
vec![format!(
|
||||
"session=; session=session-secret; session_expiry={expired_hash}"
|
||||
)],
|
||||
vec![format!(
|
||||
"session_expiry={expired_hash}; session_expiry={expired_hash}"
|
||||
)],
|
||||
vec![format!("session; session_expiry={expired_hash}")],
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
for cookie in cookies {
|
||||
headers.append(header::COOKIE, cookie.parse().unwrap());
|
||||
}
|
||||
assert!(
|
||||
resolve_request_actor(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_whoami(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.session,
|
||||
BrowserSessionState::Anonymous {}
|
||||
);
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
format!("session=session-secret; session_expiry={expired_hash}")
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(matches!(
|
||||
resolve_whoami(store, &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.session,
|
||||
BrowserSessionState::Authenticated { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_issuance_reclaims_only_rows_beyond_recovery_grace() {
|
||||
let store = seeded_store();
|
||||
let record = |secret: &str, offset| BrowserSessionRecord {
|
||||
session_id: secret.into(),
|
||||
token_hash: token_hash(secret),
|
||||
user_id: "user-auth".into(),
|
||||
created_at: now_rfc3339(),
|
||||
expires_at: rfc3339_after(Duration::seconds(offset)),
|
||||
revoked_at: None,
|
||||
};
|
||||
store
|
||||
.create_browser_session(&record("past-grace", -SESSION_EXPIRY_GRACE_SECONDS - 60))
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&token_hash("past-grace"))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
store
|
||||
.create_browser_session(&record("within-grace", -SESSION_EXPIRY_GRACE_SECONDS + 60))
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&token_hash("past-grace"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
store
|
||||
.create_browser_session(&record("new-session", BROWSER_SESSION_SECONDS))
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&token_hash("within-grace"))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&token_hash("new-session"))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6856,6 +6856,7 @@ async fn attach_server_request_context(
|
||||
}
|
||||
|
||||
fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
let auth = auth_public_config(&service.auth_api().config);
|
||||
let service = Arc::new(service);
|
||||
let origin_context = Router::new()
|
||||
.merge(server_api::server_api_axum::auth_activation_options(
|
||||
@@ -6887,7 +6888,6 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
.merge(server_api::server_api_axum::auth_device_login_approve(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_whoami(service.clone()))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
service.as_ref().clone(),
|
||||
attach_server_request_context,
|
||||
@@ -6908,12 +6908,20 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_logout(service.clone()))
|
||||
.merge(
|
||||
server_api::server_api_axum::auth_whoami(service.clone())
|
||||
.layer(middleware::from_fn(attach_server_origin_context)),
|
||||
)
|
||||
.merge(server_api::server_api_axum::auth_device_login_start(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_device_login_poll(service))
|
||||
.merge(origin_context)
|
||||
.merge(actor_context)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth,
|
||||
append_session_marker_cookie,
|
||||
))
|
||||
.layer(middleware::map_response(
|
||||
|mut response: Response| async move {
|
||||
// Current role/policy and authentication responses must not be cached
|
||||
@@ -6926,6 +6934,62 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
))
|
||||
}
|
||||
|
||||
// The typed auth operations expose one Set-Cookie. At the HTTP boundary, pair only a real
|
||||
// session Set-Cookie with a non-authenticating expiry marker, including login and logout.
|
||||
// Mirroring its attributes keeps Secure/Path/Domain/SameSite aligned without a second policy.
|
||||
async fn append_session_marker_cookie(
|
||||
State(auth): State<AuthPublicConfig>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
let marker = response
|
||||
.headers()
|
||||
.get_all(axum::http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.find_map(|value| {
|
||||
let value = value.to_str().ok()?;
|
||||
let (pair, attributes) = value.split_once(';')?;
|
||||
let (name, token) = pair.split_once('=')?;
|
||||
if name != auth.cookie_name {
|
||||
return None;
|
||||
}
|
||||
let max_age = attributes
|
||||
.split(';')
|
||||
.find_map(|attr| attr.trim().strip_prefix("Max-Age=")?.parse::<i64>().ok())?;
|
||||
let (hash, marker_age) = if max_age <= 0 {
|
||||
(String::new(), 0)
|
||||
} else {
|
||||
(
|
||||
token_hash(token),
|
||||
max_age + crate::auth::SESSION_EXPIRY_GRACE_SECONDS,
|
||||
)
|
||||
};
|
||||
let attributes = attributes
|
||||
.split(';')
|
||||
.map(|attr| {
|
||||
if attr.trim().starts_with("Max-Age=") {
|
||||
format!(" Max-Age={marker_age}")
|
||||
} else {
|
||||
attr.to_owned()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
HeaderValue::from_str(&format!(
|
||||
"{}={hash};{attributes}",
|
||||
crate::auth::session_marker_cookie_name(name)
|
||||
))
|
||||
.ok()
|
||||
});
|
||||
if let Some(marker) = marker {
|
||||
response
|
||||
.headers_mut()
|
||||
.append(axum::http::header::SET_COOKIE, marker);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router {
|
||||
let service = Arc::new(service);
|
||||
Router::new()
|
||||
@@ -8151,9 +8215,15 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
) -> std::result::Result<WhoamiResponse, server_api::RepositoryApiError> {
|
||||
Ok(WhoamiResponse {
|
||||
actor: context.actor,
|
||||
})
|
||||
let headers = contract_request_headers(&context)?;
|
||||
let api = self.auth_api();
|
||||
crate::auth::resolve_whoami(
|
||||
api.store.clone(),
|
||||
&headers,
|
||||
&auth_public_config(&api.config).cookie_name,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_repository_api_error())
|
||||
}
|
||||
|
||||
async fn auth_logout(
|
||||
@@ -30307,14 +30377,14 @@ fn issue_browser_session(
|
||||
token_hash: token_hash(&session_token),
|
||||
user_id: user.user_id.clone(),
|
||||
created_at: crate::auth::now_rfc3339(),
|
||||
expires_at: rfc3339_after(Duration::days(14)),
|
||||
expires_at: rfc3339_after(Duration::seconds(crate::auth::BROWSER_SESSION_SECONDS)),
|
||||
revoked_at: None,
|
||||
})?;
|
||||
let auth = auth_public_config(&api.config);
|
||||
let cookie = session_set_cookie(
|
||||
session_cookie_policy(&auth),
|
||||
&session_token,
|
||||
14 * 24 * 60 * 60,
|
||||
crate::auth::BROWSER_SESSION_SECONDS,
|
||||
);
|
||||
Ok((
|
||||
AuthUserResponse {
|
||||
@@ -30510,6 +30580,16 @@ fn logout_browser_session(
|
||||
.store
|
||||
.revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?;
|
||||
}
|
||||
// A missing auth cookie after Max-Age must not prevent explicit logout from revoking
|
||||
// the recovery reference. This hash is never accepted as an authentication credential.
|
||||
if let Some(hash) = parse_cookie(
|
||||
&headers,
|
||||
&crate::auth::session_marker_cookie_name(&auth.cookie_name),
|
||||
) {
|
||||
let _ = api
|
||||
.store
|
||||
.revoke_browser_session(&hash, &crate::auth::now_rfc3339())?;
|
||||
}
|
||||
Ok((
|
||||
LogoutResponse {
|
||||
status: LogoutStatus::LoggedOut,
|
||||
@@ -53509,6 +53589,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(registration_response.status(), StatusCode::OK);
|
||||
assert_auth_response_has_expiry_marker(registration_response.headers(), &cookie_name);
|
||||
let registration_cookie = registration_response
|
||||
.headers()
|
||||
.get(axum::http::header::SET_COOKIE)
|
||||
@@ -53546,6 +53627,12 @@ mod tests {
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(registered_whoami["session"]["kind"], "authenticated");
|
||||
assert!(
|
||||
registered_whoami["session"]["expires_at"]
|
||||
.as_str()
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
registered_whoami["actor"]["handle"],
|
||||
"generated-passkey-user"
|
||||
@@ -53600,6 +53687,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(login_response.status(), StatusCode::OK);
|
||||
assert_auth_response_has_expiry_marker(login_response.headers(), &cookie_name);
|
||||
let login_cookie = login_response
|
||||
.headers()
|
||||
.get(axum::http::header::SET_COOKIE)
|
||||
@@ -53684,6 +53772,169 @@ mod tests {
|
||||
assert!(logged_out_whoami["actor"].is_null());
|
||||
}
|
||||
|
||||
// Real generated auth routes, DB executor and SQLite; no auth resolution is mocked.
|
||||
#[tokio::test]
|
||||
async fn whoami_expiry_marker_survives_auth_cookie_loss_but_logout_revokes_it() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let api = test_api(workspace.path()).await;
|
||||
seed_test_api_token(api.store.as_ref(), "expiry-router");
|
||||
let user = api.store.get_user("user-expiry-router").unwrap().unwrap();
|
||||
let auth = auth_public_config(&api.config);
|
||||
let cookie_name = auth.cookie_name;
|
||||
let hash = token_hash("expired-router-secret");
|
||||
api.store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
session_id: "expired-router-session".into(),
|
||||
token_hash: hash.clone(),
|
||||
user_id: user.user_id,
|
||||
created_at: crate::auth::now_rfc3339(),
|
||||
expires_at: rfc3339_after(Duration::minutes(-1)),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let marker = format!(
|
||||
"{}={hash}",
|
||||
crate::auth::session_marker_cookie_name(&cookie_name)
|
||||
);
|
||||
let app = generated_auth_contract_router(ServerApiContractService::Workspace(api.clone()));
|
||||
for (cookie, kind) in [
|
||||
(String::new(), "anonymous"),
|
||||
(format!("{cookie_name}=expired-router-secret"), "expired"),
|
||||
(marker.clone(), "expired"),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/whoami")
|
||||
.header(axum::http::header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
|
||||
assert!(
|
||||
!response
|
||||
.headers()
|
||||
.contains_key(axum::http::header::SET_COOKIE)
|
||||
);
|
||||
let value: Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(value["actor"], Value::Null);
|
||||
assert_eq!(value["session"]["kind"], kind);
|
||||
}
|
||||
let denied = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/auth/passkeys/addition/options")
|
||||
.header(axum::http::header::COOKIE, &marker)
|
||||
.header(ORIGIN, &auth.origin)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Preserve the operation's existing auth_required/403 contract; no HTML redirect.
|
||||
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
|
||||
assert!(!denied.headers().contains_key(axum::http::header::LOCATION));
|
||||
let logout = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/auth/logout")
|
||||
.header(axum::http::header::COOKIE, &marker)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(logout.status(), StatusCode::OK);
|
||||
let cleared: Vec<_> = logout
|
||||
.headers()
|
||||
.get_all(axum::http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|v| v.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(cleared.len(), 2);
|
||||
assert!(
|
||||
cleared
|
||||
.iter()
|
||||
.all(|value| value.contains("Max-Age=0") && value.contains("HttpOnly"))
|
||||
);
|
||||
assert!(cleared.iter().any(|value| value.starts_with(&format!(
|
||||
"{}=;",
|
||||
crate::auth::session_marker_cookie_name(&cookie_name)
|
||||
))));
|
||||
assert!(api.store.resolve_browser_session(&hash).unwrap().is_none());
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/whoami")
|
||||
.header(axum::http::header::COOKIE, marker)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(value["session"]["kind"], "anonymous");
|
||||
}
|
||||
|
||||
fn assert_auth_response_has_expiry_marker(headers: &HeaderMap, cookie_name: &str) {
|
||||
let cookies: Vec<_> = headers
|
||||
.get_all(axum::http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect();
|
||||
let auth = cookies
|
||||
.iter()
|
||||
.find(|value| value.starts_with(&format!("{cookie_name}=")))
|
||||
.unwrap();
|
||||
let (_, token) = auth.split(';').next().unwrap().split_once('=').unwrap();
|
||||
let marker = cookies
|
||||
.iter()
|
||||
.find(|value| {
|
||||
value.starts_with(&format!(
|
||||
"{}=",
|
||||
crate::auth::session_marker_cookie_name(cookie_name)
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
let expected = token_hash(token);
|
||||
assert_eq!(
|
||||
parse_cookie(
|
||||
&{
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(axum::http::header::COOKIE, marker.parse().unwrap());
|
||||
headers
|
||||
},
|
||||
&crate::auth::session_marker_cookie_name(cookie_name)
|
||||
),
|
||||
Some(expected)
|
||||
);
|
||||
assert!(auth.contains(&format!("Max-Age={}", crate::auth::BROWSER_SESSION_SECONDS)));
|
||||
assert!(marker.contains(&format!(
|
||||
"Max-Age={}",
|
||||
crate::auth::BROWSER_SESSION_SECONDS + crate::auth::SESSION_EXPIRY_GRACE_SECONDS
|
||||
)));
|
||||
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Secure"] {
|
||||
assert_eq!(
|
||||
auth.contains(attribute),
|
||||
marker.contains(attribute),
|
||||
"{attribute}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() {
|
||||
for (scheme, secure) in [("http", false), ("https", true)] {
|
||||
|
||||
@@ -7131,6 +7131,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
activation::require_active(conn, &record.user_id)?;
|
||||
// Retain the real row throughout the non-authenticating recovery window, not
|
||||
// forever. Issuance amortizes reclamation without extending marker lifetime or
|
||||
// introducing another store/scheduler. Inactive stores do not grow between issues.
|
||||
let cutoff = (chrono::Utc::now()
|
||||
- chrono::Duration::seconds(crate::auth::SESSION_EXPIRY_GRACE_SECONDS))
|
||||
.to_rfc3339();
|
||||
conn.execute(
|
||||
"DELETE FROM browser_sessions WHERE julianday(expires_at) <= julianday(?1) OR julianday(expires_at) IS NULL",
|
||||
params![cutoff],
|
||||
)?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
|
||||
|
||||
Reference in New Issue
Block a user