feat: keep the Tiny connection alive and show when it is not
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
Connecting now asks for offline_access, retrying once without it if Tiny refuses the scope. Renewal failures are stored: a refused refresh token marks the connection lost and is not sent again (the Kanban previously still said "conectado"), a transient failure shows as a warning until the next renewal, and a session grant with under 12 hours left is flagged. Tiny errors on the callback return to the Kanban instead of a 422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -316,12 +316,21 @@ def tiny_test(user=Depends(operator)):
|
||||
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
|
||||
|
||||
@router.get('/api/operator/tiny/callback')
|
||||
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
|
||||
def tiny_callback(code: str = Query('', max_length=4096), state: str = Query('', max_length=128),
|
||||
error: str = Query('', max_length=128)):
|
||||
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
|
||||
single-use state an operator created is what authorises it."""
|
||||
single-use state an operator created is what authorises it. Tiny reports a
|
||||
refusal (the operator declined, or offline access is not allowed for this
|
||||
application) with `error` instead of a code."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
try:
|
||||
if error == 'invalid_scope':
|
||||
retry = tiny.TinyAuth().without_offline(state)
|
||||
audit('tiny_offline_refused')
|
||||
return RedirectResponse(retry, status_code=303)
|
||||
if error or not code:
|
||||
raise tiny.TinyError(f'Tiny returned {error or "no code"}')
|
||||
who = tiny.TinyAuth().complete(code, state)
|
||||
except tiny.TinyError:
|
||||
audit('tiny_connect_failed')
|
||||
|
||||
@@ -107,6 +107,13 @@ CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
|
||||
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Renewal health, cleared by the next successful renewal or connection.
|
||||
-- refused_at: the provider rejected the refresh token, so only a new
|
||||
-- connection helps. offline: the grant is not bound to a login session.
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_failed_at timestamptz;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_error text;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refused_at timestamptz;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS offline boolean NOT NULL DEFAULT false;
|
||||
-- Single-use states for an operator-started OAuth connection. They protect the
|
||||
-- callback, which arrives cross-site without the operator's cookie.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
|
||||
|
||||
98
app/tiny.py
98
app/tiny.py
@@ -42,6 +42,9 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
||||
# How far back to look for an order a previous delivery may already have made.
|
||||
SEARCH_DAYS = 7
|
||||
STATE_MINUTES = 10
|
||||
# Renewal runs about every four hours against a one-day refresh token, so
|
||||
# less than this left means renewals have been failing for hours.
|
||||
EXPIRY_WARNING = timedelta(hours=12)
|
||||
# Brazil has had no daylight saving since 2019; the order date is the local day.
|
||||
BRASILIA = timezone(timedelta(hours=-3))
|
||||
|
||||
@@ -86,44 +89,78 @@ class TinyAuth:
|
||||
self.http = httpx.Client(timeout=20, transport=transport)
|
||||
self.clock = clock
|
||||
|
||||
def authorize_url(self, operator):
|
||||
def authorize_url(self, operator, offline=True):
|
||||
"""Start a connection. The state is single-use, short-lived, and only an
|
||||
authenticated operator can create one, which is what protects the
|
||||
callback: Tiny's redirect back is cross-site, so the operator's
|
||||
SameSite=Strict cookie does not travel with it."""
|
||||
SameSite=Strict cookie does not travel with it.
|
||||
|
||||
offline_access asks for a grant that is not bound to a login session,
|
||||
so the connection lasts as long as it keeps being renewed. Tiny's
|
||||
documented refresh token otherwise lasts one day."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
with self.connect() as c:
|
||||
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
|
||||
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
|
||||
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
|
||||
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri, 'scope': 'openid',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'scope': 'openid offline_access' if offline else 'openid',
|
||||
'state': state})
|
||||
|
||||
def _claim(self, c, state):
|
||||
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
||||
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
||||
if not row:
|
||||
raise TinyError('Unknown or expired authorisation state')
|
||||
return row['operator']
|
||||
|
||||
def complete(self, code, state):
|
||||
"""Exchange the authorisation code; returns the operator who started it."""
|
||||
with self.connect() as c:
|
||||
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
||||
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
||||
if not row:
|
||||
raise TinyError('Unknown or expired authorisation state')
|
||||
operator = self._claim(c, state)
|
||||
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
|
||||
'redirect_uri': self.redirect_uri})
|
||||
self._store(c, tokens, row['operator'])
|
||||
return row['operator']
|
||||
self._store(c, tokens, operator)
|
||||
return operator
|
||||
|
||||
def without_offline(self, state):
|
||||
"""Tiny refused the offline_access scope for this application: spend the
|
||||
operator's state and start again with a session-bound grant."""
|
||||
with self.connect() as c:
|
||||
operator = self._claim(c, state)
|
||||
return self.authorize_url(operator, offline=False)
|
||||
|
||||
def status(self):
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
|
||||
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
|
||||
refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||
if not row:
|
||||
return {'connected': False}
|
||||
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
|
||||
return {'connected': not expired, 'connected_by': row['connected_by'],
|
||||
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = row['refresh_expires_at']
|
||||
expired = bool(expires and expires <= now)
|
||||
if row['refused_at']:
|
||||
problem = 'refused'
|
||||
elif expired:
|
||||
problem = 'expired'
|
||||
elif row['refresh_failed_at']:
|
||||
problem = 'renewal-failing'
|
||||
elif expires and expires - now < EXPIRY_WARNING:
|
||||
problem = 'expiring'
|
||||
else:
|
||||
problem = None
|
||||
return {'connected': not expired and not row['refused_at'], 'problem': problem,
|
||||
'connected_by': row['connected_by'], 'connected_at': row['connected_at'],
|
||||
'renewed_at': row['updated_at'], 'expires_at': expires, 'offline': row['offline'],
|
||||
'failed_at': row['refresh_failed_at']}
|
||||
|
||||
def access_token(self):
|
||||
"""A valid access token, refreshing (and rotating) under a row lock."""
|
||||
"""A valid access token, refreshing (and rotating) under a row lock.
|
||||
|
||||
A failed renewal is recorded after the lock is released, so the Kanban
|
||||
can show it. A refused refresh token is never tried again: only a new
|
||||
connection helps, and retrying it would only repeat the refusal."""
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
|
||||
FOR UPDATE''').fetchone()
|
||||
@@ -132,11 +169,25 @@ class TinyAuth:
|
||||
now = datetime.now(timezone.utc)
|
||||
if row['access_expires_at'] > now + timedelta(seconds=60):
|
||||
return row['access_token']
|
||||
if row['refused_at']:
|
||||
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
|
||||
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
|
||||
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
|
||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||
return tokens['access_token']
|
||||
try:
|
||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||
except (TinyError, httpx.HTTPError, ValueError) as exc:
|
||||
failure = exc
|
||||
else:
|
||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||
return tokens['access_token']
|
||||
# Only against the token that failed: another worker may have renewed since.
|
||||
with self.connect() as c:
|
||||
c.execute('''UPDATE dtf_local.provider_tokens SET refresh_failed_at=now(), refresh_error=%s,
|
||||
refused_at=CASE WHEN %s THEN now() ELSE refused_at END
|
||||
WHERE provider='tiny' AND refresh_token=%s''',
|
||||
(str(failure)[:300] or type(failure).__name__, isinstance(failure, TinyNotConnected),
|
||||
row['refresh_token']))
|
||||
raise failure
|
||||
|
||||
def _token(self, form):
|
||||
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
|
||||
@@ -152,18 +203,21 @@ class TinyAuth:
|
||||
def _store(self, c, tokens, operator, refreshed=False):
|
||||
now = datetime.now(timezone.utc)
|
||||
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
|
||||
# An offline grant reports refresh_expires_in 0: no fixed end.
|
||||
refresh_in = tokens.get('refresh_expires_in')
|
||||
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
|
||||
offline = 'offline_access' in str(tokens.get('scope') or '').split()
|
||||
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
|
||||
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
|
||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
|
||||
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at,offline)
|
||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
|
||||
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
|
||||
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
|
||||
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
|
||||
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(), offline=EXCLUDED.offline,
|
||||
refresh_failed_at=NULL, refresh_error=NULL, refused_at=NULL,
|
||||
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
|
||||
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
|
||||
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
|
||||
operator, refreshed, refreshed))
|
||||
operator, offline, refreshed, refreshed))
|
||||
|
||||
|
||||
# Orders ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user