Add logout, passkey management, and a standalone login
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s

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:
2026-10-30 14:38:48 -04:00
co-authored by Claude Sonnet 5
parent ab696a910b
commit 1a8e4aad26
12 changed files with 495 additions and 49 deletions
+137 -23
View File
@@ -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.
+34
View File
@@ -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>,
}
+4
View File
@@ -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;
+31
View File
@@ -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>,
}