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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user