Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
420 lines
12 KiB
Rust
420 lines
12 KiB
Rust
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use reqwest::blocking::{Client, Response};
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::{
|
|
model::{AuthStatus, LoginPoll, LoginStart, ScanEvent, SyncResult},
|
|
state::SharedState,
|
|
};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DeviceCodeResponse {
|
|
device_code: String,
|
|
user_code: String,
|
|
verification_uri: String,
|
|
verification_uri_complete: Option<String>,
|
|
expires_in: u64,
|
|
#[serde(default = "default_interval")]
|
|
interval: u64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TokenResponse {
|
|
access_token: String,
|
|
#[serde(default)]
|
|
refresh_token: Option<String>,
|
|
expires_in: i64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OAuthError {
|
|
error: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct AccessStateResponse {
|
|
status: String,
|
|
role: Option<String>,
|
|
}
|
|
|
|
fn default_interval() -> u64 {
|
|
5
|
|
}
|
|
|
|
fn now_unix() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as i64
|
|
}
|
|
|
|
fn client() -> Result<Client> {
|
|
Client::builder()
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.build()
|
|
.context("create HTTP client")
|
|
}
|
|
|
|
pub fn begin_login(state: &SharedState) -> Result<LoginStart> {
|
|
let config = state.config.lock().clone();
|
|
let endpoint = format!(
|
|
"{}/protocol/openid-connect/auth/device",
|
|
config.issuer.trim_end_matches('/')
|
|
);
|
|
|
|
let response = client()?
|
|
.post(endpoint)
|
|
.form(&[("client_id", config.client_id.as_str())])
|
|
.send()
|
|
.context("request Keycloak device code")?;
|
|
|
|
let response = ensure_success(response, "device authorization")?;
|
|
let body: DeviceCodeResponse = response.json().context("decode device authorization response")?;
|
|
|
|
{
|
|
let mut auth = state.auth.lock();
|
|
auth.device_code = Some(body.device_code);
|
|
auth.poll_interval = body.interval;
|
|
auth.access_token = None;
|
|
auth.refresh_token = None;
|
|
auth.expires_at_unix = None;
|
|
}
|
|
state.persist_auth()?;
|
|
|
|
Ok(LoginStart {
|
|
user_code: body.user_code,
|
|
verification_uri: body.verification_uri,
|
|
verification_uri_complete: body.verification_uri_complete,
|
|
expires_in: body.expires_in,
|
|
interval: body.interval,
|
|
})
|
|
}
|
|
|
|
pub fn poll_login(state: &SharedState) -> Result<LoginPoll> {
|
|
let config = state.config.lock().clone();
|
|
let device_code = state
|
|
.auth
|
|
.lock()
|
|
.device_code
|
|
.clone()
|
|
.ok_or_else(|| anyhow!("no device login is active"))?;
|
|
|
|
let endpoint = format!(
|
|
"{}/protocol/openid-connect/token",
|
|
config.issuer.trim_end_matches('/')
|
|
);
|
|
|
|
let response = client()?
|
|
.post(endpoint)
|
|
.form(&[
|
|
("client_id", config.client_id.as_str()),
|
|
(
|
|
"grant_type",
|
|
"urn:ietf:params:oauth:grant-type:device_code",
|
|
),
|
|
("device_code", device_code.as_str()),
|
|
])
|
|
.send()
|
|
.context("poll Keycloak device login")?;
|
|
|
|
if response.status().is_success() {
|
|
let token: TokenResponse = response.json().context("decode token response")?;
|
|
let access_token = token.access_token.clone();
|
|
{
|
|
let mut auth = state.auth.lock();
|
|
auth.access_token = Some(token.access_token);
|
|
auth.refresh_token = token.refresh_token;
|
|
auth.expires_at_unix = Some(now_unix() + token.expires_in);
|
|
auth.device_code = None;
|
|
}
|
|
state.persist_auth()?;
|
|
|
|
let access = ensure_access_state(&client()?, state, &access_token, true)
|
|
.unwrap_or(AccessStateResponse {
|
|
status: "unknown".into(),
|
|
role: None,
|
|
});
|
|
|
|
return Ok(LoginPoll {
|
|
status: "authorized".into(),
|
|
access_status: Some(access.status),
|
|
role: access.role,
|
|
});
|
|
}
|
|
|
|
let status = response.status();
|
|
let error = response
|
|
.json::<OAuthError>()
|
|
.map(|body| body.error)
|
|
.unwrap_or_else(|_| format!("http_{}", status.as_u16()));
|
|
|
|
let mapped = match error.as_str() {
|
|
"authorization_pending" | "slow_down" => "pending",
|
|
"expired_token" => "expired",
|
|
"access_denied" => "denied",
|
|
_ => return Err(anyhow!("device login failed: {error}")),
|
|
};
|
|
|
|
Ok(LoginPoll {
|
|
status: mapped.into(),
|
|
access_status: None,
|
|
role: None,
|
|
})
|
|
}
|
|
|
|
pub fn auth_status(state: &SharedState) -> Result<AuthStatus> {
|
|
let has_session = state.auth.lock().refresh_token.is_some();
|
|
if !has_session {
|
|
return Ok(AuthStatus {
|
|
signed_in: false,
|
|
access_status: "signed-out".into(),
|
|
role: None,
|
|
});
|
|
}
|
|
|
|
let access_token = ensure_access_token(state)?;
|
|
let access = ensure_access_state(&client()?, state, &access_token, true)?;
|
|
|
|
Ok(AuthStatus {
|
|
signed_in: true,
|
|
access_status: access.status,
|
|
role: access.role,
|
|
})
|
|
}
|
|
|
|
pub fn logout(state: &SharedState) -> Result<()> {
|
|
state.clear_auth()
|
|
}
|
|
|
|
fn ensure_access_state(
|
|
client: &Client,
|
|
state: &SharedState,
|
|
access_token: &str,
|
|
request_if_needed: bool,
|
|
) -> Result<AccessStateResponse> {
|
|
let config = state.config.lock().clone();
|
|
let base = config.api_base_url.trim_end_matches('/');
|
|
|
|
let response = client
|
|
.get(format!("{base}/api/v1/access/me"))
|
|
.bearer_auth(access_token)
|
|
.send()
|
|
.context("read FieldLens access status")?;
|
|
let response = ensure_success(response, "FieldLens access status")?;
|
|
let mut access: AccessStateResponse =
|
|
response.json().context("decode FieldLens access status")?;
|
|
|
|
if request_if_needed && (access.status == "none" || access.status == "rejected") {
|
|
let response = client
|
|
.post(format!("{base}/api/v1/access/request"))
|
|
.bearer_auth(access_token)
|
|
.send()
|
|
.context("request FieldLens access")?;
|
|
let response = ensure_success(response, "FieldLens access request")?;
|
|
access = response.json().context("decode FieldLens access request")?;
|
|
}
|
|
|
|
Ok(access)
|
|
}
|
|
|
|
fn ensure_access_token(state: &SharedState) -> Result<String> {
|
|
{
|
|
let auth = state.auth.lock();
|
|
if auth.expires_at_unix.unwrap_or_default() > now_unix() + 30 {
|
|
if let Some(token) = &auth.access_token {
|
|
return Ok(token.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
refresh_access_token(state)
|
|
}
|
|
|
|
fn refresh_access_token(state: &SharedState) -> Result<String> {
|
|
let config = state.config.lock().clone();
|
|
let refresh_token = state
|
|
.auth
|
|
.lock()
|
|
.refresh_token
|
|
.clone()
|
|
.ok_or_else(|| anyhow!("not signed in to FieldLens"))?;
|
|
|
|
let endpoint = format!(
|
|
"{}/protocol/openid-connect/token",
|
|
config.issuer.trim_end_matches('/')
|
|
);
|
|
let response = client()?
|
|
.post(endpoint)
|
|
.form(&[
|
|
("client_id", config.client_id.as_str()),
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", refresh_token.as_str()),
|
|
])
|
|
.send()
|
|
.context("refresh FieldLens token")?;
|
|
|
|
let failed_status = response.status();
|
|
let response = match ensure_success(response, "token refresh") {
|
|
Ok(response) => response,
|
|
Err(error) => {
|
|
if failed_status.as_u16() == 400 || failed_status.as_u16() == 401 {
|
|
let _ = state.clear_auth();
|
|
}
|
|
return Err(error);
|
|
}
|
|
};
|
|
let token: TokenResponse = response.json().context("decode refresh response")?;
|
|
|
|
let access_token = token.access_token.clone();
|
|
{
|
|
let mut auth = state.auth.lock();
|
|
auth.access_token = Some(token.access_token);
|
|
if token.refresh_token.is_some() {
|
|
auth.refresh_token = token.refresh_token;
|
|
}
|
|
auth.expires_at_unix = Some(now_unix() + token.expires_in);
|
|
}
|
|
state.persist_auth()?;
|
|
Ok(access_token)
|
|
}
|
|
|
|
pub fn sync_pending(state: &SharedState) -> Result<SyncResult> {
|
|
let pending = state.pending.lock().clone();
|
|
if pending.is_empty() {
|
|
return Ok(SyncResult {
|
|
synced: 0,
|
|
pending: 0,
|
|
});
|
|
}
|
|
|
|
let access_token = ensure_access_token(state)?;
|
|
let client = client()?;
|
|
let device_id = ensure_device(&client, state, &access_token)?;
|
|
|
|
let batch: Vec<ScanEvent> = pending.into_iter().take(100).collect();
|
|
let events: Vec<Value> = batch
|
|
.iter()
|
|
.map(|scan| {
|
|
json!({
|
|
"id": scan.id,
|
|
"aggregateId": scan.id,
|
|
"eventType": "scan.captured",
|
|
"schemaVersion": 1,
|
|
"createdAt": scan.captured_at,
|
|
"payload": {
|
|
"id": scan.id,
|
|
"rawValue": scan.raw_value,
|
|
"format": scan.format,
|
|
"duplicate": scan.duplicate,
|
|
"capturedAt": scan.captured_at,
|
|
"source": scan.source,
|
|
"sourceMetadata": scan.source_metadata,
|
|
"schemaVersion": 1
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let config = state.config.lock().clone();
|
|
let response = client
|
|
.post(format!(
|
|
"{}/api/v1/sync",
|
|
config.api_base_url.trim_end_matches('/')
|
|
))
|
|
.bearer_auth(&access_token)
|
|
.json(&json!({
|
|
"deviceId": device_id,
|
|
"schemaVersion": 1,
|
|
"events": events
|
|
}))
|
|
.send()
|
|
.context("sync print scans")?;
|
|
|
|
let response = ensure_success(response, "FieldLens sync")?;
|
|
let body: Value = response.json().context("decode FieldLens sync response")?;
|
|
let accepted: std::collections::HashSet<String> = body
|
|
.get("acknowledgements")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|ack| {
|
|
let status = ack.get("status")?.as_str()?;
|
|
if status != "accepted" && status != "duplicate" {
|
|
return None;
|
|
}
|
|
ack.get("eventId")?.as_str().map(str::to_owned)
|
|
})
|
|
.collect();
|
|
|
|
let synced = accepted.len();
|
|
{
|
|
let mut queue = state.pending.lock();
|
|
queue.retain(|scan| !accepted.contains(&scan.id));
|
|
}
|
|
state.persist_pending()?;
|
|
|
|
Ok(SyncResult {
|
|
synced,
|
|
pending: state.pending_count(),
|
|
})
|
|
}
|
|
|
|
fn ensure_device(client: &Client, state: &SharedState, access_token: &str) -> Result<String> {
|
|
let config = state.config.lock().clone();
|
|
if let Some(device_id) = config.device_id.filter(|id| !id.trim().is_empty()) {
|
|
return Ok(device_id);
|
|
}
|
|
|
|
let mut body = json!({
|
|
"installationId": config.installation_id,
|
|
"label": "FieldLens Print",
|
|
"appVersion": env!("CARGO_PKG_VERSION"),
|
|
"schemaVersion": 1
|
|
});
|
|
if !config.station_id.trim().is_empty() {
|
|
body["stationId"] = Value::String(config.station_id.clone());
|
|
}
|
|
|
|
let response = client
|
|
.post(format!(
|
|
"{}/api/v1/devices/register",
|
|
config.api_base_url.trim_end_matches('/')
|
|
))
|
|
.bearer_auth(access_token)
|
|
.json(&body)
|
|
.send()
|
|
.context("register FieldLens Print device")?;
|
|
|
|
let response = ensure_success(response, "FieldLens device registration")?;
|
|
let body: Value = response.json().context("decode device registration")?;
|
|
let device_id = body
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| anyhow!("FieldLens device registration returned no id"))?
|
|
.to_string();
|
|
|
|
state.config.lock().device_id = Some(device_id.clone());
|
|
state.persist_config()?;
|
|
Ok(device_id)
|
|
}
|
|
|
|
fn ensure_success(response: Response, operation: &str) -> Result<Response> {
|
|
if response.status().is_success() {
|
|
return Ok(response);
|
|
}
|
|
let status = response.status();
|
|
let detail = response.text().unwrap_or_default();
|
|
Err(anyhow!(
|
|
"{operation} failed with {}{}",
|
|
status.as_u16(),
|
|
if detail.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(": {detail}")
|
|
}
|
|
))
|
|
}
|