Authentication
The public API and the MCP server use OAuth 2.0 (with the OAuth 2.1 rules: mandatory PKCE S256, no implicit flow, rotating refresh tokens). The authorization server is Tauraco's own; its issuer is https://api.tauraco.io, including under the *.tauraco.ai names, online since 2026-11-06.
Endpoints
| Endpoint | Method | Standard | Role |
|---|---|---|---|
/oauth/token | POST | RFC 6749 | token issuance and refresh |
/oauth/authorize | GET | RFC 6749, PKCE S256 | start of the delegated flow, then the consent page |
/oauth/revoke | POST | RFC 7009 | token revocation |
/oauth/introspect | POST | RFC 7662 | reserved to Tauraco internal clients |
/oauth/register | POST | RFC 7591 | dynamic registration of MCP clients, when open |
/.well-known/oauth-authorization-server | GET | RFC 8414 | authorization server metadata |
/.well-known/jwks.json | GET | RFC 7517 | public signing keys |
/.well-known/oauth-protected-resource/public/v1 | GET | RFC 9728 | protected resource: public API |
/.well-known/oauth-protected-resource/mcp | GET | RFC 9728 | protected resource: MCP server |
All are served under https://api.tauraco.io and are online since 2026-11-05; dynamic registration is open. A closed module would answer 404.
Client credentials (server to server)
For software acting on behalf of the company, with no user in front of the screen: accounting software, ERP, integrator, financial institution.
- Client authentication with HTTP Basic (
client_id:client_secret) or in the body (client_secret_post). grant_type=client_credentials,scope(space-separated scopes),resource=https://api.tauraco.io/public/v1, andtenantwhen several companies have authorised the application.- Response:
access_token,token_typeBearer,expires_in600, effectivescope. No refresh token. - The grant for this flow is given by a company administrator in the Connected apps console, never through the consent page.
"""Obtenir un jeton ``client_credentials`` et lire les factures, page par page.
Get a ``client_credentials`` token and read invoices page by page.
Bibliothèque standard seule (Python 3.9+). Variables d'environnement :
``TAURACO_CLIENT_ID`` (``tc_live_…``), ``TAURACO_CLIENT_SECRET`` (``tcs_…``, affiché une
seule fois à la création de l'application), et facultativement ``TAURACO_TENANT``
(obligatoire si plusieurs entreprises ont autorisé l'application).
Les adresses par défaut sont les surfaces prévues sous ``api.tauraco.io`` ; elles ne
répondent qu'une fois l'API publique ouverte (voir la page « Versions et changelog »).
"""
from __future__ import annotations
import base64
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Callable, Dict, Iterator, Optional, Tuple
API_BASE = os.environ.get("TAURACO_API_BASE", "https://api.tauraco.io/public/v1")
TOKEN_URL = os.environ.get("TAURACO_TOKEN_URL", "https://api.tauraco.io/oauth/token")
#: Transport : (méthode, url, en-têtes, corps) -> (statut, en-têtes, corps). Remplaçable en test.
Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], Tuple[int, Dict[str, str], bytes]]
class TauracoError(RuntimeError):
"""Erreur de l'API : corps ``application/problem+json`` (RFC 9457) ou erreur OAuth."""
def __init__(self, status: int, body: dict):
self.status = status
self.code = body.get("code") or body.get("error") or "unknown"
self.request_id = body.get("request_id")
super().__init__(f"{status} {self.code}: {body.get('detail') or body.get('error_description') or ''}")
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Aucune redirection suivie : urllib renverrait sinon l'en-tête ``Authorization`` (jeton)
à l'hôte de destination. L'API Tauraco ne redirige jamais ; un 3xx est rendu tel quel."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_OPENER = urllib.request.build_opener(_NoRedirect)
def http_transport(method: str, url: str, headers: Dict[str, str],
body: Optional[bytes]) -> Tuple[int, Dict[str, str], bytes]:
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with _OPENER.open(request, timeout=30) as response: # noqa: S310 (URL maîtrisée)
return response.status, dict(response.headers.items()), response.read()
except urllib.error.HTTPError as err:
return err.code, dict(err.headers.items()), err.read()
def get_token(client_id: str, client_secret: str, scopes: str, *, tenant: Optional[str] = None,
resource: str = API_BASE, transport: Transport = http_transport) -> dict:
"""``POST /oauth/token`` (``grant_type=client_credentials``), authentification HTTP Basic."""
form = {"grant_type": "client_credentials", "scope": scopes, "resource": resource}
if tenant:
form["tenant"] = tenant
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode("ascii")
status, _headers, raw = transport("POST", TOKEN_URL, {
"Authorization": f"Basic {basic}",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}, urllib.parse.urlencode(form).encode())
payload = json.loads(raw or b"{}")
if status != 200:
raise TauracoError(status, payload)
return payload # {"access_token", "token_type": "Bearer", "expires_in": 600, "scope"}
def iterate(path: str, token: str, params: Optional[Dict[str, str]] = None, *,
transport: Transport = http_transport, max_retries: int = 3) -> Iterator[dict]:
"""Toutes les lignes d'une liste paginée par curseur (``data``, ``next_cursor``, ``has_more``)."""
query = dict(params or {})
retries = 0
while True:
url = f"{API_BASE}{path}?{urllib.parse.urlencode(query)}" if query else f"{API_BASE}{path}"
status, headers, raw = transport("GET", url, {"Authorization": f"Bearer {token}",
"Accept": "application/json"}, None)
if status == 429 and retries < max_retries:
retries += 1
time.sleep(int(headers.get("Retry-After") or headers.get("retry-after") or 1))
continue
payload = json.loads(raw or b"{}")
if status != 200:
raise TauracoError(status, payload)
retries = 0
yield from payload.get("data", [])
if not payload.get("has_more") or not payload.get("next_cursor"):
return
query["cursor"] = payload["next_cursor"]
def main() -> None:
token = get_token(os.environ["TAURACO_CLIENT_ID"], os.environ["TAURACO_CLIENT_SECRET"],
"invoices:read", tenant=os.environ.get("TAURACO_TENANT"))["access_token"]
for invoice in iterate("/invoices", token, {"status": "PAID", "limit": "100"}):
# Montants : chaînes décimales en unités majeures ("1180000" XOF), jamais des flottants.
print(invoice["number"], invoice.get("customer_name"), invoice["total"], invoice["currency"])
if __name__ == "__main__":
main()
Authorization code and PKCE (delegated access)
For an application acting on behalf of a user: an AI assistant connected through MCP, an interactive third-party application.
- The client redirects to
/oauth/authorizewithresponse_type=code,client_id,redirect_uri(exact match with a declared address),code_challengeandcode_challenge_method=S256,state,scopeandresource. - The user signs in to Tauraco, picks the company among those where they hold an active role, sees the redirect host in clear and accepts or denies the scopes.
- The client receives
code,stateandiss(RFC 9207), then exchanges the code (valid for 5 minutes, single use) for a 60-minute access token and a 30-day refresh token. - Every refresh returns a new refresh token. Reusing a refresh token that was already exchanged revokes the whole token family and the grant.
Delegated access carries the user's rights in the chosen company, never more: if the user loses their role, their account or access to the company, the application loses access on the next call.
Access token
A JWT signed with RS256 (RFC 9068), header typ at+jwt, kid published in the JWKS. It holds iss, aud (tauraco-public-api or tauraco-mcp), sub, client_id, tenant_id, grant_id, scope, jti, iat and exp; never a role, an e-mail address or a name. Treat it as opaque: its shape may change.
A token is only valid for its audience: a public API token is refused by the MCP server, and vice versa. Tauraco application session tokens are never accepted by the public API.
Revocation and lifecycle
On every call, Tauraco re-reads the application (active), the grant (active, not expired), the bearer user (active, not deleted) and the company (active). A suspended application, a revoked grant or a delegating user without an active role give 401 grant_revoked from the next request on. Rotating a secret invalidates the old one immediately.
Scopes
Write scopes imply the matching read (invoices:write gives invoices:read, exports:write gives exports:read, bank:info-requests:write gives bank:requests:read, bank:reconciliation:write gives bank:repayments:read). A scope is checked on top of the application's rights: it never grants more than a user of the company could see.
| Scope | Access | Clients |
|---|---|---|
company:read | company profile, calling application | partner, MCP |
customers:read | customers | partner, MCP |
customers:write | create customers | partner |
suppliers:read | suppliers (without bank details) | partner, MCP |
invoices:read | customer invoices and their lines | partner, MCP |
invoices:write | create customer invoices | partner |
bills:read | supplier bills | partner, MCP |
accounting:read | chart of accounts, journals, entries, trial balance, general ledger | partner, MCP |
payments:read | payments received | partner, MCP |
payments:write | record payments | partner |
treasury:read | cash movements | partner, MCP |
purchases:read | purchase orders | partner, MCP |
products:read | products and services | partner, MCP |
inventory:read | inventory items, movements and balances | partner, MCP |
tenders:read | relayed public tenders | partner, MCP |
markets:read | reserved, no resource in v1 | partner, MCP |
financing:read | the company's financing requests | partner, MCP |
documents:read | document contents, on top of the parent's scope | partner, MCP |
guarantees:read | reserved, no resource in v1 | partner, MCP |
collateral:read | Collateral deposits and security interests | partner, MCP |
exports:read | exports and their files | partner, MCP |
exports:write | request an export | partner, MCP |
webhooks:manage | manage webhook endpoints | partner |
mcp:read | connect to the MCP server (required by every /mcp request) | MCP |
bank:inbox:read | the institution's inbox | bank |
bank:requests:read | received financing requests | bank |
bank:documents:read | request documents | bank |
bank:info-requests:write | requests for additional information | bank |
bank:collateral:read | security interests linked to requests | bank |
bank:repayments:read | instalments and repayments | bank |
bank:reconciliation:write | reconcile repayments | bank |
Tauraco "internal" clients receive the same scopes as a partner. A bank client only receives bank:* scopes.
OAuth errors
The /oauth/* endpoints answer in the RFC 6749 format ({"error":"invalid_grant","error_description":"…"}): invalid_request, invalid_client (401, with WWW-Authenticate: Basic), invalid_grant, unauthorized_client, unsupported_grant_type, unsupported_response_type, invalid_scope, invalid_target (RFC 8707), access_denied (consent denied), temporarily_unavailable (token endpoint rate limit or service unavailable); dynamic registration adds invalid_redirect_uri and invalid_client_metadata (RFC 7591). Public API errors are described in Error handling.
Good practice
- Request only the scopes you need; the administrator sees them all before authorising.
- Cache the token until
expires_inminus a margin; do not request a token per call (the/oauth/tokenendpoint has its own rate limit). - Never log a secret or a token; log
client_idandX-Request-Id.