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
+15
View File
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Logout and passkey management: `logout`, `list_passkeys`, and
`delete_passkey`. `logout` regenerates every system-issued token on the
whole company, not just the caller's, so it ends every session sharing
it. Passkey *registration* is not wrapped — it needs a WebAuthn ceremony
only a browser can drive. Adds `Passkey`.
- `InvoiceNinjaClient::login`, an associated function (not a method, since
there is no token yet to build a client with) that exchanges an email and
password for an API token, optionally with a one-time password for
two-factor accounts. Adds `LoginResponse` and `LoginCompanyUser`; the
nested company/user/account data the server also returns is left in
`extra` rather than modelled. Does not cover passkey login, SSO, or
`/login/precheck`.
## [0.2.0] - 2026-10-25 ## [0.2.0] - 2026-10-25
### Added ### Added
+13 -9
View File
@@ -361,24 +361,28 @@ changes, doc tweaks) does not need an entry.
## Coverage ## Coverage
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users` **All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 371 methods: 30 resources with CRUD + bulk, 3 with CRUD (which isn't one). 375 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, 4 with invitation-key downloads, 16 with only, 4 with entity actions, 4 with invitation-key downloads, 16 with
document uploads, and 107 hand-written. document uploads, and 111 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, their invitation-key PDF and e-document the four action-capable resources, their invitation-key PDF and e-document
downloads, `report`, `chart`, `search`, `statics`, `send_email`, downloads, `report`, `chart`, `search`, `statics`, `send_email`,
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing `refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
routes, and the QuickBooks, Stripe, calendar, Yodlee and GoCardless routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
integrations. integrations, `logout`, passkey list/delete, and a standalone `login`.
**`GOALS.md`'s roadmap is closed** — every route an API-token client can **`GOALS.md`'s roadmap is closed** — every route an API-token client can
reach is wrapped. What is left out is left out for a reason, not pending: reach is wrapped. What is left out is left out for a reason, not pending:
the auth routes (a different auth model, and an open question in `GOALS.md`), passkey registration and passkey login (WebAuthn ceremonies only a browser
the client-portal family and the OAuth and provider-webhook routes (all can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`,
outside `/api/v1`, so they don't fit the client's `base_url` or its token), `/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open
`POST /quickbooks/settings` (a registered route with no controller method, question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail
so it 500s), and a few company and user extras. OAuth linking — an integration, not login/logout), the client-portal family
and the OAuth and provider-webhook routes (all outside `/api/v1`, so they
don't fit the client's `base_url` or its token), `POST /quickbooks/settings`
(a registered route with no controller method, so it 500s), and a few
company and user extras.
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every **`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
gap, and the reason for each gap. gap, and the reason for each gap.
+61 -4
View File
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
> exists or what it returns. > exists or what it returns.
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is **All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
not a resource group). 371 methods in total. not a resource group). 375 methods in total.
## Resources with full CRUD + bulk ## Resources with full CRUD + bulk
@@ -434,6 +434,60 @@ established that a purged record reads back as a **400**, not a 404. A
password containing a control character cannot be a header value and goes out password containing a control character cannot be a header value and goes out
as `X-API-PASSWORD-BASE64` instead. as `X-API-PASSWORD-BASE64` instead.
## Logout, passkeys, and login
`/login`, `/logout`, and the passkey routes used to sit together under one
"Auth" gap. They don't share a shape: `/logout` and the passkey *settings*
routes are plain `token_auth` endpoints like everything else this crate
wraps, while `/login` sits in a different, unauthenticated middleware group
because the caller has no token yet to prove who they are.
| Endpoint | Method | Needs an existing client? |
| --- | --- | --- |
| `POST /logout` | `logout` | yes |
| `GET /settings/passkeys` | `list_passkeys` | yes |
| `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes |
| `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** |
**`logout` has a bigger blast radius than its name suggests.** The
controller doesn't revoke the caller's token — it regenerates *every*
`is_system` token belonging to the whole company, logging out every user
and device that shares it, including the one making the call.
**Passkey registration is not wrapped.** `POST /settings/passkeys/options`
and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a
browser talking to a platform authenticator can complete — there is no
attestation object for this crate to produce standalone. Listing and
deleting an existing credential need no such ceremony, so those are the two
operations offered.
**`login` returns the server's full "first load" payload** — nested
company, clients, invoices, designs, and more under each `CompanyUser`
entry — because it reuses the same transformer as a browser's initial page
load. This crate does not model that nested tree; [`LoginCompanyUser`]
types only `is_owner`, `is_admin`, and the `token` it exists to produce,
and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`]
is what a caller actually needs — feed it to
[`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`].
`login` optionally takes a one-time password for accounts with two-factor
auth enabled. It does not support passkey login, SSO (`oauth_login`,
`oidc/*`), `/login/precheck`, or a self-hosted instance's optional
`X-API-SECRET` header — each needs a ceremony, a redirect, or a header this
single email/password call does not send.
`/refresh` and `/refresh_react` were in the old "Auth" bucket too, but
they're not an authentication concept: `BaseController::refreshResponse`
is the same bulk, `updated_at`-filtered account dump as `/login`'s first
load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as
a separate open question, not something to bolt onto auth — not
implemented, for the same reason.
`/connected_account` (linking a Gmail OAuth account) was also filed under
"Auth". It's an integration-linking endpoint, not login/logout, so it
belongs with Stripe/QuickBooks under [Integrations](#integrations) above —
also not implemented.
## Per-resource extras ## Per-resource extras
Routes that sit beside a resource's CRUD set, each with its own response Routes that sit beside a resource's CRUD set, each with its own response
@@ -491,8 +545,11 @@ check `routes/api.php`).
- **User extras** (not password-protected, unlike the rest of `/users`) — - **User extras** (not password-protected, unlike the rest of `/users`) —
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`, `POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
`POST /user/{id}/reconfirm` `POST /user/{id}/reconfirm`
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`, - **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
passkey routes above for what's covered (`login`, `logout`, passkey list/delete) and
what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey
registration and passkey login, `/refresh`, `/refresh_react`, and
`/connected_account`
- **Migration** — `/migration/start`. Moves a company between instances and - **Migration** — `/migration/start`. Moves a company between instances and
is driven by the v4 migration tool, not by an SDK caller. is driven by the v4 migration tool, not by an SDK caller.
- **Invoice extras** — `delivery_note`, `payment_schedule`, - **Invoice extras** — `delivery_note`, `payment_schedule`,
@@ -526,7 +583,7 @@ check `routes/api.php`).
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`, `/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`, `/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`, `/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
`/smtp/check`, `/password_timeout`, `/refresh_react`, `/webcron`, `/smtp/check`, `/password_timeout`, `/webcron`,
`/token_hash_router`, `/reactivate_email/{bounce_id}`, `/token_hash_router`, `/reactivate_email/{bounce_id}`,
`/protected_download/{hash}`, `/confirm_purchase`, `/protected_download/{hash}`, `/confirm_purchase`,
`/exports/preview/{hash}`, `/templates/preview/{hash}` `/exports/preview/{hash}`, `/templates/preview/{hash}`
+24 -7
View File
@@ -228,6 +228,24 @@ last item, so **the roadmap is closed**: every route in `routes/api.php` that
an API-token client can reach is now wrapped. It did land in pieces, as the an API-token client can reach is now wrapped. It did land in pieces, as the
item predicted — import/export first, then the integrations. item predicted — import/export first, then the integrations.
**Done:** the auth routes, once split apart — the single "auth routes"
label had quietly merged four unrelated things. `logout` and the passkey
list/delete routes sit behind the same `token_auth` middleware as every
other wrapped endpoint, so they needed no new client type, just methods
(and a documented warning that `logout` rotates every token on the whole
company, not just the caller's). `login` *does* need a different entry
point — there is no token yet to build an `InvoiceNinjaClient` with — but
that turned out to be a standalone associated function, not a second
client type: `InvoiceNinjaClient::login` trades an email and password for
a `LoginResponse` carrying a token, which then goes to `new` or `hosted`
as normal. Passkey *registration* and passkey *login* stay unwrapped
because they're WebAuthn ceremonies a browser has to drive; `/refresh` and
`/refresh_react` turned out to be the bulk `updated_at` sync dump already
parked below, not an auth concept, so they stay out for that reason
instead; and `/connected_account` (Gmail OAuth linking) was filed under
the wrong heading — it belongs with the Integrations done item above. See
`COVERAGE.md`'s "Logout, passkeys, and login" section for the detail.
As the item also predicted, it is the least SDK-shaped part of the API, and As the item also predicted, it is the least SDK-shaped part of the API, and
the typing reflects that: most of these return `serde_json::Value`, because the typing reflects that: most of these return `serde_json::Value`, because
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless, their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
@@ -244,18 +262,17 @@ a registered route with no controller method behind it, so it 500s — there is
nothing to wrap until upstream adds the handler. nothing to wrap until upstream adds the handler.
What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap
list explains: the auth routes (an open question below), the client-portal list explains: the passkey and SSO ceremonies and `/login/precheck`
family, the provider webhooks, and a few company and user extras. Keep that (each needs something — a browser, a redirect — this client doesn't have),
file current — it names exact counts, so it goes stale quietly. `/refresh`/`/refresh_react` (the sync-engine shape parked below), the
client-portal family, the provider webhooks, and a few company and user
extras. Keep that file current — it names exact counts, so it goes stale
quietly.
## Open questions ## Open questions
These are genuinely undecided. Deciding one is a change to this file. These are genuinely undecided. Deciding one is a change to this file.
- **Auth routes** (`/login`, `/logout`, `/refresh`, passkeys) are in the gap
list, but they are a different authentication model from the API token
this client is built around. Including them may mean a second client type
rather than more methods on this one. Not obviously in scope.
- **`chart` is still generic** over `T: DeserializeOwned`, because the - **`chart` is still generic** over `T: DeserializeOwned`, because the
eleven chart endpoints share no response shape. Per-chart structs would eleven chart endpoints share no response shape. Per-chart structs would
be eleven small models of unstable payloads; unclear whether that earns be eleven small models of unstable payloads; unclear whether that earns
+4 -2
View File
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage ## Coverage
Every one of the API's 34 resource groups, plus users — 371 methods. Each Every one of the API's 34 resource groups, plus users — 375 methods. Each
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
(where the API offers one) `bulk`: (where the API offers one) `bulk`:
@@ -116,7 +116,9 @@ resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
Plus documents, entity actions and PDF downloads — by record id, or by Plus documents, entity actions and PDF downloads — by record id, or by
invitation key for the PDF and XML a given contact receives — reports, invitation key for the PDF and XML a given contact receives — reports,
charts, search, statics, email sending, refunds, and health checks. charts, search, statics, email sending, refunds, health checks, logout,
passkey management, and a standalone `login` to exchange credentials for
a token.
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint **See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
inventory**, including everything that is deliberately *not* wrapped and why. inventory**, including everything that is deliberately *not* wrapped and why.
+137 -23
View File
@@ -10,10 +10,11 @@ use crate::models::{
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule, Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken, BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck, Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse, ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse,
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense, MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote,
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription,
SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
Webhook,
}; };
use crate::retry::{self, RetryPolicy}; use crate::retry::{self, RetryPolicy};
use crate::upload::FileUpload; use crate::upload::FileUpload;
@@ -63,6 +64,32 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
Ok(utf8_percent_encode(value, PATH_SEGMENT)) 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, /// Standard base64 with padding. Only used for the one header that needs it,
/// which is not worth a dependency. /// which is not worth a dependency.
fn base64(bytes: &[u8]) -> String { fn base64(bytes: &[u8]) -> String {
@@ -135,25 +162,7 @@ pub struct InvoiceNinjaClient {
impl InvoiceNinjaClient { impl InvoiceNinjaClient {
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> { pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
let base_url = base_url.into(); let base_url = validate_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 mut headers = header::HeaderMap::new(); let mut headers = header::HeaderMap::new();
let mut token_value = header::HeaderValue::from_str(&api_token.into()) let mut token_value = header::HeaderValue::from_str(&api_token.into())
@@ -338,6 +347,76 @@ impl InvoiceNinjaClient {
Self::new(HOSTED_BASE_URL, api_token) 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 { fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/')) 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 /// Activity log, email history, and document previews. Each has a response
/// shape of its own (read from the upstream controllers, and for the activity /// 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. /// 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 import_export;
mod invoice; mod invoice;
mod location; mod location;
mod login;
mod passkey;
mod payment; mod payment;
mod payment_term; mod payment_term;
mod product; mod product;
@@ -58,6 +60,8 @@ pub use health::{HealthCheck, PhpVersion};
pub use import_export::{ExportJob, ImportPreview}; pub use import_export::{ExportJob, ImportPreview};
pub use invoice::{Credit, Invoice, InvoiceItem, Quote}; pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
pub use location::Location; pub use location::Location;
pub use login::{LoginCompanyUser, LoginResponse};
pub use passkey::Passkey;
pub use payment::{Payment, PaymentInvoice}; pub use payment::{Payment, PaymentInvoice};
pub use payment_term::PaymentTerm; pub use payment_term::PaymentTerm;
pub use product::Product; 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>,
}
+126
View File
@@ -2931,3 +2931,129 @@ extra_test!(
|c| c.nordigen_institutions(), |c| c.nordigen_institutions(),
|got| assert_eq!(got[0]["id"], "BANK_X") |got| assert_eq!(got[0]["id"], "BANK_X")
); );
// --- logout, passkeys, login --------------------------------------------
extra_test!(
logout_posts_and_returns_the_message,
"POST",
"/logout",
message(),
|c| c.logout(),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
list_passkeys_decodes_the_data_envelope,
"GET",
"/settings/passkeys",
json!({ "data": [
{ "id": "p1", "name": "Work laptop", "created_at": "2026-01-01T00:00:00.000000Z", "last_used_at": 1700000000 },
{ "id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z", "last_used_at": null },
] }),
|c| c.list_passkeys(),
|got| {
assert_eq!(got.len(), 2);
assert_eq!(got[0].last_used_at, Some(1700000000));
assert_eq!(got[1].last_used_at, None);
}
);
/// The server answers a delete with `{"message": ...}`, not an empty body,
/// but `delete_passkey` discards it like every other `delete_*`.
#[tokio::test]
async fn delete_passkey_discards_the_message_body() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/settings/passkeys/p1"))
.respond_with(ResponseTemplate::new(200).set_body_json(message()))
.expect(1)
.mount(&server)
.await;
client(&server).delete_passkey("p1").await.unwrap();
}
/// `login` has no client yet, so it is an associated function taking the
/// base url directly rather than a method.
#[tokio::test]
async fn login_posts_credentials_and_decodes_the_company_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.and(wiremock::matchers::body_json(json!({
"email": "me@example.com",
"password": "correct-horse-battery-staple",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{
"is_owner": true,
"is_admin": true,
"token": { "id": "t1", "token": "new-api-token" },
"company": { "id": "c1" },
}
]
})))
.expect(1)
.mount(&server)
.await;
let login = InvoiceNinjaClient::login(
&server.uri(),
"me@example.com",
"correct-horse-battery-staple",
None,
)
.await
.expect("login should succeed");
assert_eq!(login.data.len(), 1);
let token = login.data[0].token.as_ref().expect("token include present");
assert_eq!(token.token, "new-api-token");
// The nested company blob is not modelled, but it is not dropped either.
assert!(login.data[0].extra.contains_key("company"));
}
/// An optional one-time password goes out alongside the credentials when
/// the caller supplies one, and is left out entirely otherwise.
#[tokio::test]
async fn login_sends_one_time_password_only_when_given() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.and(wiremock::matchers::body_json(json!({
"email": "me@example.com",
"password": "pw",
"one_time_password": "123456",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": [] })))
.expect(1)
.mount(&server)
.await;
InvoiceNinjaClient::login(&server.uri(), "me@example.com", "pw", Some("123456"))
.await
.expect("login should succeed");
}
/// A failed login surfaces the server's validation message like any other
/// non-2xx response.
#[tokio::test]
async fn login_failure_is_an_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"message": "These credentials do not match our records."
})))
.mount(&server)
.await;
let err = InvoiceNinjaClient::login(&server.uri(), "me@example.com", "wrong", None)
.await
.expect_err("400");
assert!(
matches!(&err, Error::Api { status, .. } if status.as_u16() == 400),
"{err:?}"
);
}
+3
View File
@@ -72,6 +72,9 @@ fn every_model_absorbs_a_null_or_missing_field() {
Invoice, Invoice,
InvoiceItem, InvoiceItem,
Location, Location,
LoginCompanyUser,
LoginResponse,
Passkey,
Payment, Payment,
PaymentInvoice, PaymentInvoice,
PaymentTerm, PaymentTerm,
+43 -4
View File
@@ -1,10 +1,10 @@
use invoiceninja_sdk::models::{ use invoiceninja_sdk::models::{
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken, BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project, GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey,
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate,
Webhook, User, Vendor, Webhook,
}; };
use serde_json::json; use serde_json::json;
@@ -763,3 +763,42 @@ fn statics_ids_accept_both_strings_and_numbers() {
assert_eq!(statics.industries[0].id, "3"); assert_eq!(statics.industries[0].id, "3");
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty"); assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty");
} }
#[test]
fn login_response_types_the_token_and_preserves_the_rest() {
let payload = json!({
"data": [{
"is_owner": true,
"is_admin": true,
"token": { "id": "t1", "token": "abc" },
"company": { "id": "c1", "settings": {} },
"user": { "id": "u1", "email": "me@example.com" }
}]
});
let login: LoginResponse = serde_json::from_value(payload).unwrap();
assert!(login.data[0].is_owner);
let token = login.data[0].token.as_ref().unwrap();
assert_eq!(token.token, "abc");
let back = serde_json::to_value(&login).unwrap();
assert_eq!(back["data"][0]["company"]["id"], "c1");
assert_eq!(back["data"][0]["user"]["email"], "me@example.com");
}
#[test]
fn passkey_round_trip_preserves_last_used_at() {
let used = json!({
"id": "p1", "name": "Laptop", "created_at": "2026-01-01T00:00:00.000000Z",
"last_used_at": 1700000000
});
let never_used = json!({
"id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z",
"last_used_at": null
});
let used: Passkey = serde_json::from_value(used).unwrap();
let never_used: Passkey = serde_json::from_value(never_used).unwrap();
assert_eq!(used.last_used_at, Some(1700000000));
assert_eq!(never_used.last_used_at, None);
}