daraja-auth ยท diff

git:20261019.15b8109 to git:20261019.9e3c915

48 added, 52 removed. Audit A to A.

---
name: daraja-auth
- description: Generate, cache and refresh Safaricom Daraja OAuth 2.0 access tokens using the Consumer Key and Consumer Secret (client-credentials grant, HTTP Basic auth). Use when writing the token helper for any M-Pesa API call, debugging 401/400 auth errors, or handling token expiry and concurrency.
+ description: Generates, caches and refreshes Safaricom Daraja OAuth 2.0 access tokens from the Consumer Key and Secret (client-credentials grant with HTTP Basic auth), including single-flight refresh, 401 retry and multi-instance caching. Use when writing the token helper for any M-Pesa API, or when debugging 400.008.xx, 401 or 404.001.03 "Invalid Access Token" errors.
---
- # Daraja Authentication (OAuth 2.0 Client Credentials)
+ # Daraja Authentication
## Mechanism
- 1. Take your app's **Consumer Key** (client ID) and **Consumer Secret** (client secret) from the Daraja portal.
- 2. Build `base64(consumer_key + ":" + consumer_secret)`.
- 3. `GET {BASE}/oauth/v1/generate?grant_type=client_credentials` with header `Authorization: Basic <encoded>`.
- 4. Response:
- ```json
- { "access_token": "c9SQxWWhmdVRlyh0zh8gZDTkubVF", "expires_in": "3599" }
- ```
- Note `expires_in` is a **string** of seconds (~1 hour).
- 5. Every product call sends `Authorization: Bearer <access_token>`.
+ ```
+ GET {BASE}/oauth/v1/generate?grant_type=client_credentials
+ Authorization: Basic base64(CONSUMER_KEY + ":" + CONSUMER_SECRET)
- Basic auth is acceptable only because the channel is TLS โ€” never call over plain HTTP, never log the header.
+ 200 โ†’ {"access_token": "c9SQxWWhmdVRlyh0zh8gZDTkubVF", "expires_in": "3599"}
+ ```
+ - `expires_in` is a **string** of seconds, about one hour.
+ - Product calls send `Authorization: Bearer <access_token>` and are POST. Only the token call is GET.
+ - Basic auth is acceptable because the channel is TLS. Never log the header or the token.
+
+ Quick check of your credentials (stdlib only; it never prints the token):
+
+ ```bash
+ MPESA_CONSUMER_KEY=... MPESA_CONSUMER_SECRET=... MPESA_ENV=sandbox python scripts/check_token.py
+ ```
+
## Reference implementation (Python)
```python
import base64, os, threading, time, requests
BASE_URL = os.environ.get("MPESA_BASE_URL", "https://sandbox.safaricom.co.ke")
- KEY = os.environ["MPESA_CONSUMER_KEY"] # fail fast if missing
- SECRET = os.environ["MPESA_CONSUMER_SECRET"]
+ KEY = os.environ["MPESA_CONSUMER_KEY"].strip() # KeyError = fail fast at startup
+ SECRET = os.environ["MPESA_CONSUMER_SECRET"].strip()
+ SAFETY_MARGIN_S = 60 # refresh before expiry so in-flight calls never carry a dead token
_lock = threading.Lock()
_token, _expires_at = None, 0.0
- SAFETY_MARGIN = 60 # refresh a minute early
- def get_access_token(force: bool = False) -> str:
+ def get_access_token(force=False) -> str:
global _token, _expires_at
- if not force and _token and time.time() < _expires_at - SAFETY_MARGIN:
+ if not force and _token and time.time() < _expires_at - SAFETY_MARGIN_S:
return _token
- with _lock: # prevent a thundering herd of refreshes
- if not force and _token and time.time() < _expires_at - SAFETY_MARGIN:
+ with _lock: # single-flight: avoids a thundering herd of refreshes
+ if not force and _token and time.time() < _expires_at - SAFETY_MARGIN_S:
return _token
auth = base64.b64encode(f"{KEY}:{SECRET}".encode()).decode()
- r = requests.get(
- f"{BASE_URL}/oauth/v1/generate",
- params={"grant_type": "client_credentials"},
- headers={"Authorization": f"Basic {auth}"},
- timeout=15,
- )
+ r = requests.get(f"{BASE_URL}/oauth/v1/generate",
+ params={"grant_type": "client_credentials"},
+ headers={"Authorization": f"Basic {auth}"}, timeout=15)
r.raise_for_status()
data = r.json()
- _token = data["access_token"]
- _expires_at = time.time() + int(data["expires_in"])
+ _token, _expires_at = data["access_token"], time.time() + int(data["expires_in"])
return _token
def daraja_post(path: str, payload: dict) -> dict:
- """POST with proactive caching + reactive 401 fallback (retry once)."""
+ """Proactive cache + reactive fallback: on 401, refresh once and retry."""
for attempt in (0, 1):
- r = requests.post(
- f"{BASE_URL}{path}", json=payload, timeout=30,
- headers={"Authorization": f"Bearer {get_access_token(force=attempt == 1)}"},
- )
+ r = requests.post(f"{BASE_URL}{path}", json=payload, timeout=30,
+ headers={"Authorization": f"Bearer {get_access_token(force=attempt == 1)}"})
if r.status_code != 401:
return r.json()
r.raise_for_status()
```
- ## Node.js equivalent (core idea)
-
- ```js
- const auth = Buffer.from(`${process.env.MPESA_CONSUMER_KEY}:${process.env.MPESA_CONSUMER_SECRET}`).toString("base64");
- const res = await fetch(`${BASE_URL}/oauth/v1/generate?grant_type=client_credentials`, {
- headers: { Authorization: `Basic ${auth}` },
- });
- const { access_token, expires_in } = await res.json();
- ```
+ Node.js uses the same idea: `Buffer.from(`${key}:${secret}`).toString("base64")`, then `fetch(url, { headers: { Authorization: `Basic ${auth}` } })`.
- ## Lifecycle rules
+ ## Rules
- - **Proactive**: refresh when `now >= expires_at - margin`.
- - **Reactive**: on `401` / `404.001.03 Invalid Access Token`, force refresh and retry **once**.
- - **Concurrency**: one refresh at a time (lock / single-flight). In multi-instance deployments store the token in Redis with a TTL slightly shorter than `expires_in`, guarded by a distributed lock (`SET key val NX PX`).
- - Don't request a new token per API call โ€” it adds latency and can get you rate-limited.
- - Store the token in memory/cache only; never in the database alongside business data or in logs.
+ - Cache the token. Requesting a new one on every call adds latency and can trigger spike-arrest errors (`500.003.02`).
+ - Refresh **proactively** before expiry, and **reactively** on a 401 or `404.001.03`, retrying exactly once.
+ - **Several app instances**: keep the token in Redis with a TTL shorter than `expires_in`, and guard refreshes with `SET lock NX PX`.
+ - Keep the token in memory or cache only: never in the database, logs or error trackers.
- ## Common failures
+ ## Errors
| Symptom | Cause |
|---|---|
- | `400` with `errorCode 400.008.01` / "Invalid Authentication passed" | Wrong key/secret, whitespace/newline in env var, wrong environment |
- | `400.008.02` "Invalid grant type" | Missing/misspelled `grant_type=client_credentials` |
- | `401` on product call | Expired token or token from the other environment |
- | Works in sandbox, fails live | Using sandbox keys against `api.safaricom.co.ke` or app not approved for that product |
+ | `400.008.01` Invalid authentication type | Not Basic auth, wrong key or secret, or a trailing newline in the env var |
+ | `400.008.02` Invalid grant type | Missing or misspelled `grant_type=client_credentials` |
+ | Empty-body 400 on the token call | Wrong key/secret, or keys from the other environment |
+ | 401 / `404.001.03` on a product call | Token expired, or token from the other environment |
+ | `404.001.04` Invalid authenticator header | Bearer header missing, or GET used on a product API |
+ | Token OK but product says not permitted | That API product is not added to the Daraja app |
+
+ ## MCP
+
+ `mpesa:mpesa_check_credentials` fetches a token with the configured keys and reports the environment and expiry. It never returns the token.