feat(auth): separate anonymous entry from expired session recovery

This commit is contained in:
2026-10-20 03:52:48 +09:00
parent e5749b9be0
commit bfeee487fd
66 changed files with 3672 additions and 429 deletions
+366 -8
View File
@@ -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()
);
}
}
+257 -6
View File
@@ -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)] {
+10
View File
@@ -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)"#,