login: add helpers to pass cookie values when parsing login responses

depending on the context a client may or may not have access to
HttpOnly cookies. this change allows them to pass such values to
`proxmox-login` to take them into account when parsing login
responses.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
This commit is contained in:
Shannon Sterz 2025-03-04 15:42:38 +01:00 committed by Wolfgang Bumiller
parent f137b5e528
commit b28e98ca99

View File

@ -162,10 +162,27 @@ impl Login {
&self, &self,
body: &T, body: &T,
) -> Result<TicketResult, ResponseError> { ) -> Result<TicketResult, ResponseError> {
self.response_bytes(body.as_ref()) self.response_bytes(None, body.as_ref())
} }
fn response_bytes(&self, body: &[u8]) -> Result<TicketResult, ResponseError> { /// Parse the result body of a [`CreateTicket`](api::CreateTicket) API request taking into
/// account potential tickets obtained via a `Set-Cookie` header.
///
/// On success, this will either yield an [`Authentication`] or a [`SecondFactorChallenge`] if
/// Two-Factor-Authentication is required.
pub fn response_with_cookie_ticket<T: ?Sized + AsRef<[u8]>>(
&self,
cookie_ticket: Option<Ticket>,
body: &T,
) -> Result<TicketResult, ResponseError> {
self.response_bytes(cookie_ticket, body.as_ref())
}
fn response_bytes(
&self,
cookie_ticket: Option<Ticket>,
body: &[u8],
) -> Result<TicketResult, ResponseError> {
use ticket::TicketResponse; use ticket::TicketResponse;
let response: api::ApiResponse<api::CreateTicketResponse> = serde_json::from_slice(body)?; let response: api::ApiResponse<api::CreateTicketResponse> = serde_json::from_slice(body)?;
@ -175,6 +192,14 @@ impl Login {
return Err("ticket response contained unexpected userid".into()); return Err("ticket response contained unexpected userid".into());
} }
// if a ticket was provided via a cookie, use it like a normal ticket
if let Some(ticket) = cookie_ticket {
check_ticket_userid(ticket.userid(), &self.userid)?;
return Ok(TicketResult::Full(
self.authentication_for(ticket, response)?,
));
}
let ticket: TicketResponse = match response.ticket { let ticket: TicketResponse = match response.ticket {
Some(ticket) => ticket.parse()?, Some(ticket) => ticket.parse()?,
None => return Err("missing ticket".into()), None => return Err("missing ticket".into()),
@ -183,15 +208,7 @@ impl Login {
Ok(match ticket { Ok(match ticket {
TicketResponse::Full(ticket) => { TicketResponse::Full(ticket) => {
check_ticket_userid(ticket.userid(), &self.userid)?; check_ticket_userid(ticket.userid(), &self.userid)?;
TicketResult::Full(Authentication { TicketResult::Full(self.authentication_for(ticket, response)?)
csrfprevention_token: response
.csrfprevention_token
.ok_or("missing CSRFPreventionToken in ticket response")?,
clustername: response.clustername,
api_url: self.api_url.clone(),
userid: response.username,
ticket,
})
} }
TicketResponse::Tfa(ticket, challenge) => { TicketResponse::Tfa(ticket, challenge) => {
@ -205,6 +222,22 @@ impl Login {
} }
}) })
} }
fn authentication_for(
&self,
ticket: Ticket,
response: api::CreateTicketResponse,
) -> Result<Authentication, ResponseError> {
Ok(Authentication {
csrfprevention_token: response
.csrfprevention_token
.ok_or("missing CSRFPreventionToken in ticket response")?,
clustername: response.clustername,
api_url: self.api_url.clone(),
userid: response.username,
ticket,
})
}
} }
/// This is the result of a ticket call. It will either yield a final ticket, or a TFA challenge. /// This is the result of a ticket call. It will either yield a final ticket, or a TFA challenge.
@ -310,10 +343,24 @@ impl SecondFactorChallenge {
&self, &self,
body: &T, body: &T,
) -> Result<Authentication, ResponseError> { ) -> Result<Authentication, ResponseError> {
self.response_bytes(body.as_ref()) self.response_bytes(None, body.as_ref())
} }
fn response_bytes(&self, body: &[u8]) -> Result<Authentication, ResponseError> { /// Deal with the API's response object to extract the ticket either from a cookie or the
/// response itself.
pub fn response_with_cookie_ticket<T: ?Sized + AsRef<[u8]>>(
&self,
cookie_ticket: Option<Ticket>,
body: &T,
) -> Result<Authentication, ResponseError> {
self.response_bytes(cookie_ticket, body.as_ref())
}
fn response_bytes(
&self,
cookie_ticket: Option<Ticket>,
body: &[u8],
) -> Result<Authentication, ResponseError> {
let response: api::ApiResponse<api::CreateTicketResponse> = serde_json::from_slice(body)?; let response: api::ApiResponse<api::CreateTicketResponse> = serde_json::from_slice(body)?;
let response = response.data.ok_or("missing response data")?; let response = response.data.ok_or("missing response data")?;
@ -321,7 +368,21 @@ impl SecondFactorChallenge {
return Err("ticket response contained unexpected userid".into()); return Err("ticket response contained unexpected userid".into());
} }
let ticket: Ticket = response.ticket.ok_or("no ticket in response")?.parse()?; // get the ticket from:
// 1. the cookie if possible -> new HttpOnly authentication outside of the browser
// 2. just the `ticket_info` -> new HttpOnly authentication inside a browser context or
// similar, assume the ticket is handle by that
// 3. the `ticket` field -> old authentication flow where we handle the ticket ourselves
let ticket: Ticket = cookie_ticket
.ok_or(ResponseError::from("no ticket in response"))
.or_else(|e| {
response
.ticket_info
.or(response.ticket)
.ok_or(e)
.and_then(|t| t.parse().map_err(|e: TicketError| e.into()))
})?;
check_ticket_userid(ticket.userid(), &self.userid)?; check_ticket_userid(ticket.userid(), &self.userid)?;
Ok(Authentication { Ok(Authentication {