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.

Authorisation once, refresh forever Left to right: the grower authorises in a browser; the redirect returns an authorisation code with a state value; the code is exchanged for an access token and a rotated refresh token which are committed to the token store in one transaction; thereafter a scheduled proactive refresh runs before the access token expires, and each exchange writes the rotated token back in the same transaction. 1 · Grower consents browser redirect, once PKCE + state 2 · Code exchange code → access + refresh verify state before use 3 · Token store write rotated token and commit in one transaction 4 · Proactive refresh at expiry − 120 s, under a per-grower lock every refresh rotates the stored token Separate grant, easily missed: a valid token still returns empty collections until the grower accepts the connection for each organisation — check the connections link and surface it.

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.

The decision every task makes before its first request A decision diagram. A task needing a token checks whether the stored access token has more than 120 seconds of life left. If it does, the task uses it directly. If it does not, the task refreshes inside a per-grower advisory lock so that concurrent tasks serialise rather than exchanging the same refresh token twice. Task needs a token for one grower access token valid for > 120 s? yes Use the stored access token no network call, no rotation, no risk of losing the refresh token no Refresh under a per-grower advisory lock exchange and persist in one transaction; concurrent tasks queue instead of racing Refreshing on a clock rather than on a 401 is what keeps concurrent tasks from invalidating each other's tokens.

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.

PYTHON
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:

PYTHON
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 connections link 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.
Four connection states, and why only one of them is an outage A table of four authorisation states: fully connected, authorised but with the organisation connection still pending, a refresh token that has been rotated away, and a grant revoked by the grower. Each row gives the API response and the correct scheduler behaviour. Connection state What the API returns What the scheduler should do Authorised and connected Full collections Sync on the normal cadence Authorised, connection pending Empty collections, connections link Surface the link to the grower Refresh token rotated away 400 on the token endpoint Mark for re-authorisation, stop polling Revoked by the grower 401 that survives a refresh Stop scheduling; do not retry hourly
  • 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_access is 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.