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
+61 -4
View File
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
> exists or what it returns.
**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
@@ -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
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
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`) —
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
`POST /user/{id}/reconfirm`
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
passkey routes
- **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
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
is driven by the v4 migration tool, not by an SDK caller.
- **Invoice extras** — `delivery_note`, `payment_schedule`,
@@ -526,7 +583,7 @@ check `routes/api.php`).
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
`/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}`,
`/protected_download/{hash}`, `/confirm_purchase`,
`/exports/preview/{hash}`, `/templates/preview/{hash}`