Authenticating with the John Deere Operations Center API
One-sentence answer: run the OAuth 2 authorisation code flow once per grower, store the rotated refresh token transactionally, and refresh a few minutes before expiry — a token store that does those three things will run unattended for a season.
Context
Machine data belongs to the grower, not to your application, so there is no service credential that reads it. Everything flows from a one-time browser consent that yields a refresh token, and that refresh token is the entire relationship: lose it and the grower has to sit down at a computer during harvest to click through a consent screen again. The two ways teams lose it are refreshing reactively (a burst of concurrent 401s triggers several simultaneous exchanges, and all but one of the resulting tokens are dead on arrival) and refreshing non-transactionally (the exchange succeeds, the process dies, the new token is gone). Both are avoidable in about forty lines.
This guide is the credential layer under machine data APIs for management-system integration; the collection walking and pagination happen once a token exists.
Prerequisites
Beyond the packages listed on the parent topic: a registered developer application with a client identifier and secret, a redirect URI you control and have registered exactly, and a table or secrets backend to hold tokens per grower.
Step-by-step
1. Discover the endpoints. Providers publish an OpenID configuration document; read it once at start-up and cache it rather than hard-coding URLs that change.
2. Build the authorisation URL with a random state, a PKCE code verifier and challenge, and the scopes your integration actually needs.
3. Handle the callback, verify state matches, and exchange the code.
4. Persist inside the transaction that performed the exchange.
5. Refresh proactively behind a per-grower advisory lock so concurrent tasks cannot exchange the same token twice.
import base64
import hashlib
import os
import secrets
import time
from dataclasses import dataclass
import httpx
from sqlalchemy import create_engine, text
WELL_KNOWN = "https://signin.johndeere.com/oauth2/aus78tnlaysMraFhC1t7/.well-known/oauth-authorization-server"
SCOPES = "ag1 ag2 org1 offline_access"
REFRESH_MARGIN_S = 120
@dataclass(frozen=True)
class Endpoints:
authorize: str
token: str
def discover(client: httpx.Client) -> Endpoints:
doc = client.get(WELL_KNOWN).raise_for_status().json()
return Endpoints(doc["authorization_endpoint"], doc["token_endpoint"])
def start_authorization(ep: Endpoints, client_id: str, redirect_uri: str) -> tuple[str, str, str]:
"""Return (url, state, code_verifier) — store the latter two against the pending session."""
state = secrets.token_urlsafe(24)
verifier = base64.urlsafe_b64encode(os.urandom(40)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
url = (
f"{ep.authorize}?response_type=code&client_id={client_id}"
f"&redirect_uri={httpx.URL(redirect_uri)}&scope={SCOPES.replace(' ', '%20')}"
f"&state={state}&code_challenge={challenge}&code_challenge_method=S256"
)
return url, state, verifier
def exchange_code(ep: Endpoints, client: httpx.Client, *, code: str, verifier: str,
client_id: str, client_secret: str, redirect_uri: str) -> dict:
resp = client.post(ep.token, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
}, auth=(client_id, client_secret))
resp.raise_for_status()
payload = resp.json()
assert "refresh_token" in payload, (
"no refresh token returned — the offline_access scope is missing from the request")
return payload
def store_tokens(dsn: str, grower_id: str, payload: dict) -> None:
"""Write access and refresh tokens atomically; commit before the caller uses them."""
engine = create_engine(dsn, future=True)
with engine.begin() as conn: # one transaction, committed on exit
conn.execute(text("""
INSERT INTO grower_token (grower_id, access_token, refresh_token, expires_at)
VALUES (:g, :a, :r, now() + make_interval(secs => :e))
ON CONFLICT (grower_id) DO UPDATE
SET access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
expires_at = EXCLUDED.expires_at
"""), {"g": grower_id, "a": payload["access_token"],
"r": payload["refresh_token"], "e": int(payload["expires_in"])})
def access_token(dsn: str, ep: Endpoints, client: httpx.Client, grower_id: str,
client_id: str, client_secret: str) -> str:
"""Return a valid access token, refreshing under a per-grower lock if it is close to expiry."""
engine = create_engine(dsn, future=True)
with engine.begin() as conn:
# Advisory lock keyed on the grower: concurrent tasks serialise here, not at the provider.
conn.execute(text("SELECT pg_advisory_xact_lock(hashtext(:g))"), {"g": grower_id})
row = conn.execute(text(
"SELECT access_token, refresh_token, extract(epoch FROM expires_at - now()) AS ttl "
"FROM grower_token WHERE grower_id = :g"), {"g": grower_id}).one()
if row.ttl > REFRESH_MARGIN_S:
return row.access_token
resp = client.post(ep.token, data={
"grant_type": "refresh_token",
"refresh_token": row.refresh_token,
}, auth=(client_id, client_secret))
if resp.status_code in (400, 401):
conn.execute(text("UPDATE grower_token SET needs_reauth = true WHERE grower_id = :g"),
{"g": grower_id})
raise PermissionError(f"grower {grower_id} must re-authorise: {resp.text[:200]}")
resp.raise_for_status()
payload = resp.json()
conn.execute(text("""
UPDATE grower_token
SET access_token = :a,
refresh_token = :r,
expires_at = now() + make_interval(secs => :e)
WHERE grower_id = :g
"""), {"g": grower_id, "a": payload["access_token"],
"r": payload.get("refresh_token", row.refresh_token),
"e": int(payload["expires_in"])})
return payload["access_token"]
Inline verification — run this immediately after the first exchange:
with httpx.Client(timeout=30.0) as c:
token = access_token(DSN, endpoints, c, "grower-1", CLIENT_ID, CLIENT_SECRET)
orgs = c.get("https://partnerapi.deere.com/platform/organizations",
headers={"Authorization": f"Bearer {token}",
"Accept": "application/vnd.deere.axiom.v3+json"}).json()
values = orgs.get("values", [])
print(f"{len(values)} organisation(s) visible")
pending = [o["name"] for o in values
if any(l.get("rel") == "connections" for l in o.get("links", []))]
assert not pending, (
f"these organisations still need the grower to accept the connection: {pending}")
Settings worth being deliberate about
| Setting | Default here | Why it matters |
|---|---|---|
REFRESH_MARGIN_S |
120 s | Refresh this far before expiry. Too small and ordinary container clock skew lets an expired token reach a request; too large and every task refreshes on every run, multiplying rotations and the chance of a lost token |
| Scopes | ag1 ag2 org1 offline_access |
Request only what the integration reads. A broad scope set makes the consent screen alarming to growers, and offline_access is the one that must never be dropped — without it there is no refresh token at all |
| Advisory lock key | hashtext(grower_id) |
Serialises refreshes per grower inside the database. A global lock would serialise every grower behind one; no lock at all invalidates tokens under concurrency |
| Token store | one row per grower | Keep needs_reauth on the same row so the scheduler can skip a grower without a separate lookup |
| HTTP timeout | 30 s, 10 s connect | The token endpoint is fast; a long timeout here only delays the discovery that the identity provider is down |
state entropy |
24 bytes | Guards the callback against forgery. It must be stored server-side against the pending session and compared, not merely echoed |
The one setting with no safe default is the redirect URI: it must match the registered value byte for byte, including the trailing slash. A mismatch produces an error on the provider’s own page rather than in your logs, which is why it is usually diagnosed last.
Gotchas and edge cases
- A valid token with empty collections is the connections grant, not a bug. The organisation payload carries a
connectionslink until the grower has accepted access for that organisation. Surface the link in your own interface rather than waiting for support tickets about missing fields.
-
Refresh tokens rotate, so concurrency is the enemy. Two tasks refreshing the same grower simultaneously produce two exchanges, and the provider invalidates the first result. The advisory lock above serialises them inside the database; without it, an eight-way parallel sync will invalidate its own credentials within minutes of an expiry boundary.
-
offline_accessis what returns a refresh token at all. Omitting it produces a working access token that expires in an hour and cannot be renewed — an integration that appears to work in development and dies overnight. -
Never log the token payload. Access tokens are bearer credentials; a refresh token in a log file is a standing grant to a grower’s business data. Log the grower identifier, the expiry, and nothing else.
-
Clock skew shortens the margin. A container whose clock drifts two minutes ahead will consider tokens valid past their real expiry. The 120-second margin absorbs ordinary skew; if your fleet drifts further than that, fix the clock rather than raising the margin.
This guide is part of Machine Data APIs for Management-System Integration — see there for the resource tree, incremental sync contract and field matching that follow authorisation.
Related
- Paginating and Rate-Limiting Management-System Pulls — what to do with the token once you have it
- Machine Data APIs for Management-System Integration — the full integration this credential layer sits under
- Orchestrating Seasonal Pipelines with Airflow — per-grower task isolation, and treating a revoked token as a business event