OAuth
An API key is right for your own company's scripts. An application that people in many companies will use (a shop connector, a phone app, an accounting sync) should not ask them to make a key and paste it in. It uses OAuth 2.0 instead: the person is sent to Foudra, chooses their company, signs in, sees what your application is asking for, and allows it. Your application gets a token that acts as that person, with no more than their role allows, and they can disconnect it at any time from Connected apps.
The flow is the authorization code grant with PKCE (RFC 6749, RFC 7636). PKCE with S256 is required of every application.
Registering your application
Applications are registered by Foudra: ask with Report a problem in the app, saying what the application is and the exact redirect addresses it will use. You get a client_id, and a client secret if your application runs on a server where a secret stays secret. An application on a phone or in a browser is a public client and has no secret; PKCE protects it.
The flow
import base64
import hashlib
import secrets
import urllib.parse
import requests
CLIENT_ID = "your-client-id"
CLIENT_SECRET = "…" # server-side applications only
REDIRECT_URI = "https://app.example.com/foudra/callback"
# 1. Send the person to Foudra, with a fresh verifier and state each time.
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
# Keep verifier and state in the person's session until they come back.
authorize_url = "https://foudra.com/oauth/authorize?" + urllib.parse.urlencode({
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "read write",
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
})
# 2. They come back to REDIRECT_URI?code=…&state=… (or ?error=access_denied).
# Check state matches, then exchange the code within ten minutes:
def exchange(code):
r = requests.post("https://foudra.com/oauth/token", timeout=30,
auth=(CLIENT_ID, CLIENT_SECRET), # a public client sends client_id in data instead
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": REDIRECT_URI, "code_verifier": verifier})
r.raise_for_status()
return r.json()
The answer:
{"access_token": "fdo_…", "token_type": "Bearer", "expires_in": 3600,
"refresh_token": "fdor_…", "scope": "read write",
"company": {"slug": "acme", "name": "Acme Widgets", "api_base_url": "https://acme.foudra.com"}}
Keep all of it. Call the API at company.api_base_url with Authorization: Bearer fdo_…, exactly as with a key: the token works only for that company. scope is what was actually granted, which may be less than you asked for.
Refreshing
An access token lasts an hour. Before or after it expires, swap the refresh token for a new pair:
def refresh(saved):
r = requests.post("https://foudra.com/oauth/token", timeout=30, auth=(CLIENT_ID, CLIENT_SECRET),
data={"grant_type": "refresh_token", "refresh_token": saved["refresh_token"]})
r.raise_for_status()
new = r.json()
store(new) # save the new refresh token FIRST
return new
Refresh tokens rotate. Each refresh returns a new refresh token and spends the old one. If a spent refresh token is ever presented again, Foudra takes it as stolen and revokes everything from that sign-in, and the person has to allow your application again. So save the new refresh token before you use the new access token, never retry a refresh with the old one, and if two workers share a connection, let only one of them refresh. A refresh token unused for 90 days expires.
An expired or revoked access token gets 401 with WWW-Authenticate: Bearer error="invalid_token": refresh, and if that fails, ask the person to connect again.
Scopes and roles
The scopes are the API key scopes: read, write and approve, each including the one before. What you get is capped by your registration and by the person's role: viewers and operators read, planners write, admins and owners approve. The cap is applied again on every request, so if the person is given a lesser role or leaves the company, your access shrinks or stops with it. Changes are made in their name, and actions report "acted_as": {"user": …, "application": …}.
Disconnecting
When the person disconnects on your side, tell Foudra:
requests.post("https://foudra.com/oauth/revoke", timeout=30, auth=(CLIENT_ID, CLIENT_SECRET),
data={"token": saved["refresh_token"]})
Revoking the refresh token revokes its access tokens too. If they disconnect on Foudra's side, your next request gets 401 and the refresh fails with invalid_grant.
Errors
The token endpoint answers as RFC 6749 says: {"error": "invalid_grant", "error_description": "…"} with status 400, or invalid_client with 401. invalid_grant means the code or refresh token is spent, expired or revoked: start the flow again.