Add logout, passkey management, and a standalone login
The "auth routes" open question in GOALS.md conflated four unrelated things once the upstream routes and controllers were checked: logout and the passkey settings routes sit behind the same token_auth middleware as everything else this crate wraps, login needs a credential exchange since there's no token yet, refresh/refresh_react turned out to be the bulk sync-dump endpoint already parked as a separate open question, and connected_account is Gmail OAuth linking that belongs with Integrations. Adds logout, list_passkeys, delete_passkey (passkey registration is left out — it needs a WebAuthn ceremony a browser has to drive), and InvoiceNinjaClient::login as an associated function returning a lean LoginResponse that types only is_owner/is_admin/token and leaves the server's much larger nested payload in extra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+137
-23
@@ -10,10 +10,11 @@ use crate::models::{
|
||||
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
|
||||
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse,
|
||||
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
|
||||
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep,
|
||||
SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse,
|
||||
MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote,
|
||||
RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription,
|
||||
SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
||||
Webhook,
|
||||
};
|
||||
use crate::retry::{self, RetryPolicy};
|
||||
use crate::upload::FileUpload;
|
||||
@@ -63,6 +64,32 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
|
||||
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
||||
}
|
||||
|
||||
/// Validates a base URL and strips its trailing slash.
|
||||
///
|
||||
/// Shared by [`InvoiceNinjaClient::new`] and
|
||||
/// [`InvoiceNinjaClient::login`], the one other place a base URL arrives
|
||||
/// from a caller rather than from an existing client. Parsing up front
|
||||
/// means a malformed URL — most often a missing scheme, as in
|
||||
/// `invoicing.co/api/v1` — fails here instead of as a confusing per-request
|
||||
/// error.
|
||||
fn validate_base_url(base_url: String) -> Result<String> {
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Error::InvalidBaseUrl(base_url));
|
||||
}
|
||||
|
||||
let parsed = reqwest::Url::parse(trimmed)
|
||||
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(Error::InvalidBaseUrl(format!(
|
||||
"{trimmed}: expected an http or https url, got scheme `{}`",
|
||||
parsed.scheme()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(trimmed.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// Standard base64 with padding. Only used for the one header that needs it,
|
||||
/// which is not worth a dependency.
|
||||
fn base64(bytes: &[u8]) -> String {
|
||||
@@ -135,25 +162,7 @@ pub struct InvoiceNinjaClient {
|
||||
|
||||
impl InvoiceNinjaClient {
|
||||
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
|
||||
let base_url = base_url.into();
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Error::InvalidBaseUrl(base_url));
|
||||
}
|
||||
|
||||
// Parse up front rather than letting a malformed base URL surface as a
|
||||
// confusing per-request failure. A missing scheme is the common typo
|
||||
// (`invoicing.co/api/v1`), and it fails here instead of on every call.
|
||||
let parsed = reqwest::Url::parse(trimmed)
|
||||
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(Error::InvalidBaseUrl(format!(
|
||||
"{trimmed}: expected an http or https url, got scheme `{}`",
|
||||
parsed.scheme()
|
||||
)));
|
||||
}
|
||||
|
||||
let base_url = trimmed.trim_end_matches('/').to_string();
|
||||
let base_url = validate_base_url(base_url.into())?;
|
||||
|
||||
let mut headers = header::HeaderMap::new();
|
||||
let mut token_value = header::HeaderValue::from_str(&api_token.into())
|
||||
@@ -338,6 +347,76 @@ impl InvoiceNinjaClient {
|
||||
Self::new(HOSTED_BASE_URL, api_token)
|
||||
}
|
||||
|
||||
/// Exchanges an email and password for an API token, for the one case
|
||||
/// `new` cannot cover: there is no token yet to build a client with.
|
||||
///
|
||||
/// `POST /login` sits outside the `token_auth` middleware every other
|
||||
/// wrapped endpoint uses, so this is a standalone async function rather
|
||||
/// than a method — nothing in `self` could be used to make the request.
|
||||
/// It builds its own one-shot `reqwest::Client` and does not go through
|
||||
/// `req` or `execute`, and, like every other `POST` in this crate, is
|
||||
/// not retried on a 5xx or a timeout.
|
||||
///
|
||||
/// On success the server answers with one entry per company the account
|
||||
/// has access to, each carrying that company's token
|
||||
/// ([`LoginCompanyUser::token`](crate::models::LoginCompanyUser::token))
|
||||
/// — feed one into [`new`](Self::new) (or [`hosted`](Self::hosted)) to
|
||||
/// get a working client:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn run() -> invoiceninja_sdk::Result<()> {
|
||||
/// use invoiceninja_sdk::InvoiceNinjaClient;
|
||||
///
|
||||
/// let login = InvoiceNinjaClient::login(
|
||||
/// "https://invoicing.co/api/v1",
|
||||
/// "me@example.com",
|
||||
/// "correct-horse-battery-staple",
|
||||
/// None,
|
||||
/// )
|
||||
/// .await?;
|
||||
/// let token = &login.data[0].token.as_ref().expect("token include").token;
|
||||
/// let client = InvoiceNinjaClient::hosted(token)?;
|
||||
/// # let _ = client;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// `one_time_password` is only consulted when the account has
|
||||
/// two-factor authentication enabled; pass `None` otherwise. Passkey
|
||||
/// login, SSO (`oauth_login`, `oidc/*`), and a self-hosted instance's
|
||||
/// optional `X-API-SECRET` are not handled — each needs a ceremony or a
|
||||
/// header this function does not send, and none fits a single
|
||||
/// email/password call.
|
||||
pub async fn login(
|
||||
base_url: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
one_time_password: Option<&str>,
|
||||
) -> Result<LoginResponse> {
|
||||
let base_url = validate_base_url(base_url.to_string())?;
|
||||
|
||||
let mut body = serde_json::json!({ "email": email, "password": password });
|
||||
if let Some(otp) = one_time_password {
|
||||
body["one_time_password"] = serde_json::Value::String(otp.to_string());
|
||||
}
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.build()?;
|
||||
let response = http
|
||||
.post(format!("{base_url}/login"))
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.header(
|
||||
header::ACCEPT,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
Self::decode(response).await
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
||||
}
|
||||
@@ -1909,6 +1988,41 @@ impl InvoiceNinjaClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout and passkey management. Unlike the credential exchange in
|
||||
/// [`login`](Self::login), these sit behind the normal `token_auth`
|
||||
/// middleware — the same one every other wrapped endpoint uses — so they
|
||||
/// are plain methods here rather than a standalone function.
|
||||
impl InvoiceNinjaClient {
|
||||
/// Regenerates every system-issued API token on the **whole company**,
|
||||
/// not just the one this client is using.
|
||||
///
|
||||
/// `LogoutController::index` looks up the company behind the caller's
|
||||
/// token and replaces every `is_system` token belonging to it with a
|
||||
/// fresh random value — one call logs out every user and every device
|
||||
/// sharing that company. The token this client was built with is among
|
||||
/// them, so a subsequent call through it will fail; building a new
|
||||
/// client with a fresh token is the only way back in.
|
||||
pub async fn logout(&self) -> Result<MessageResponse> {
|
||||
self.post_unwrapped("logout", &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
/// Lists the WebAuthn passkeys registered to the current user.
|
||||
pub async fn list_passkeys(&self) -> Result<Vec<Passkey>> {
|
||||
self.get_list("settings/passkeys", &ListParams::default())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deletes a passkey credential. The server refuses with a 403 if
|
||||
/// `id` belongs to a different user than the one the token
|
||||
/// authenticates.
|
||||
///
|
||||
/// Registering a new passkey is not wrapped — see [`Passkey`] for why.
|
||||
pub async fn delete_passkey(&self, id: &str) -> Result<()> {
|
||||
self.delete_one(&format!("settings/passkeys/{}", seg(id)?))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Activity log, email history, and document previews. Each has a response
|
||||
/// shape of its own (read from the upstream controllers, and for the activity
|
||||
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::CompanyToken;
|
||||
|
||||
/// Response of `POST /login` (via [`crate::InvoiceNinjaClient::login`]).
|
||||
///
|
||||
/// One entry per company the account can access. The full upstream payload
|
||||
/// also nests the whole company (clients, invoices, designs, ...), the
|
||||
/// user, and the account under each entry — this crate does not model that;
|
||||
/// it stays reachable, losslessly, through [`LoginCompanyUser::extra`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub data: Vec<LoginCompanyUser>,
|
||||
}
|
||||
|
||||
/// One company membership returned by a login, carrying that company's API
|
||||
/// token.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginCompanyUser {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_owner: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_admin: bool,
|
||||
/// The token to pass to [`InvoiceNinjaClient::new`](crate::InvoiceNinjaClient::new)
|
||||
/// for this company. Only absent if the server ever omits the `token`
|
||||
/// include; every server-side `first_load` request requests it.
|
||||
#[serde(default)]
|
||||
pub token: Option<CompanyToken>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
@@ -17,6 +17,8 @@ mod health;
|
||||
mod import_export;
|
||||
mod invoice;
|
||||
mod location;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod payment;
|
||||
mod payment_term;
|
||||
mod product;
|
||||
@@ -58,6 +60,8 @@ pub use health::{HealthCheck, PhpVersion};
|
||||
pub use import_export::{ExportJob, ImportPreview};
|
||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||
pub use location::Location;
|
||||
pub use login::{LoginCompanyUser, LoginResponse};
|
||||
pub use passkey::Passkey;
|
||||
pub use payment::{Payment, PaymentInvoice};
|
||||
pub use payment_term::PaymentTerm;
|
||||
pub use product::Product;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A WebAuthn passkey credential registered against the current user.
|
||||
/// Served from `GET /settings/passkeys`.
|
||||
///
|
||||
/// Registering a new passkey is not wrapped: the server's `/settings/
|
||||
/// passkeys/options` and `/settings/passkeys` routes exist to drive a
|
||||
/// WebAuthn ceremony that only a browser, talking to its platform
|
||||
/// authenticator, can complete — there is no attestation for this crate to
|
||||
/// produce on its own. Listing and deleting an existing credential need no
|
||||
/// such ceremony, so those are the two passkey operations this crate
|
||||
/// offers.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Passkey {
|
||||
/// Hashed id — pass to [`crate::InvoiceNinjaClient::delete_passkey`] to
|
||||
/// remove this credential.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub created_at: String,
|
||||
/// Unix timestamp of the credential's last successful authentication,
|
||||
/// or `None` if it has never been used to log in.
|
||||
#[serde(default)]
|
||||
pub last_used_at: Option<i64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
Reference in New Issue
Block a user