diff --git a/app/api/operator.py b/app/api/operator.py index 0e97b9b..eef71b8 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -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') diff --git a/app/schema.sql b/app/schema.sql index 414f8ea..5ff9bbc 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -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 ( diff --git a/app/tiny.py b/app/tiny.py index e334d47..19e09fd 100644 --- a/app/tiny.py +++ b/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_atnow() 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 ------------------------------------------------------------------ diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 666f7c6..747761b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -278,6 +278,17 @@ From the report already sent. These are dated promises, not backlog. `--confirmar`, through the worker's own `deliver`, then proves the duplicate guard by search first and only then by a second delivery. Not yet run against the client's account. + **Staying connected (2026-09-25):** Tiny documents a 4-hour access token and + a 1-day refresh token; the worker renews about every 4 hours. The connection + now asks for `offline_access` (listed by Tiny's Keycloak; if refused for this + application the callback retries once without it). Renewal failures are + stored: a refused refresh token marks the connection lost and is not retried, + a transient failure shows as a warning until the next success, and a + session grant with under 12 hours left is flagged. Previously a refused + refresh still showed "Tiny conectado". Whether Tiny grants offline access, + and whether a session grant has an undocumented maximum, is only known on + the client's account. There is no alert channel yet (no e-mail; WhatsApp + is fake): problems show on the Kanban only. - `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions must survive checkout before an output engine can reproduce the approved job). **Built (2026-09-24):** each paid item gets a PDF the width of the film and diff --git a/tests/tiny_oauth_test.py b/tests/tiny_oauth_test.py index a89dd57..5e8cadd 100644 --- a/tests/tiny_oauth_test.py +++ b/tests/tiny_oauth_test.py @@ -33,8 +33,12 @@ def run(): def exercise(): issued = [] + server = {'mode': 'session', 'calls': 0} def token_server(request): + server['calls'] += 1 + if server['mode'] == 'down': + return httpx.Response(503, text='unavailable') form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()} assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret') if form['grant_type'] == 'authorization_code': @@ -44,8 +48,13 @@ def exercise(): return httpx.Response(400, json={'error': 'invalid_grant'}) n = len(issued) + 1 issued.append(f'refresh-{n}') + if server['mode'] == 'offline': + return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400, + 'refresh_token': f'refresh-{n}', 'refresh_expires_in': 0, + 'scope': 'openid offline_access profile'}) return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400, - 'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400}) + 'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400, + 'scope': 'openid profile'}) auth = TinyAuth(transport=httpx.MockTransport(token_server)) assert auth.status() == {'connected': False} @@ -59,6 +68,7 @@ def exercise(): query = {k: v[0] for k, v in parse_qs(url.query).items()} assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client' assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30 + assert query['scope'] == 'openid offline_access' try: auth.complete('good-code', 'forged-state') raise AssertionError('a state no operator created must be refused') @@ -72,6 +82,7 @@ def exercise(): pass status = auth.status() assert status['connected'] and status['connected_by'] == 'operator@example.test' + assert status['problem'] is None and not status['offline'] and status['expires_at'] assert auth.access_token() == 'access-1' print('PASS: operator-started state is required, single-use, and stores the connection') @@ -85,6 +96,23 @@ def exercise(): assert auth.access_token() == 'access-2' print('PASS: expiring access token refreshed once, refresh token rotated and stored') + # Tiny unreachable: the failure is shown, the connection kept, and the next + # renewal clears it. + with connect() as c: + c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'") + server['mode'] = 'down' + try: + auth.access_token() + raise AssertionError('an unreachable token server must fail the renewal') + except httpx.HTTPError: + pass + status = auth.status() + assert status['connected'] and status['problem'] == 'renewal-failing' and status['failed_at'] + server['mode'] = 'session' + assert auth.access_token() == 'access-3' + assert auth.status()['problem'] is None + print('PASS: a failed renewal is shown and cleared by the next successful one') + # A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop. with connect() as c: c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked' @@ -94,12 +122,63 @@ def exercise(): raise AssertionError('a refused refresh must report the connection as lost') except TinyNotConnected: pass + status = auth.status() + assert not status['connected'] and status['problem'] == 'refused' + calls = server['calls'] + try: + auth.access_token() + raise AssertionError('a refused connection must stay refused') + except TinyNotConnected: + pass + assert server['calls'] == calls, 'a refused refresh token must not be sent again' with connect() as c: c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'", (datetime.now(timezone.utc) - timedelta(seconds=1),)) assert not auth.status()['connected'] print('PASS: revoked or expired connections report that Tiny must be connected again') + # Reconnecting with an offline grant: no daily end, and the refusal is cleared. + server['mode'] = 'offline' + state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0] + auth.complete('good-code', state) + status = auth.status() + assert status['connected'] and status['offline'] and status['expires_at'] is None + assert status['problem'] is None + with connect() as c: + c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'") + auth.access_token() + assert auth.status()['offline'] and auth.status()['expires_at'] is None + print('PASS: an offline grant is stored without a daily expiry and survives renewal') + + # Tiny refusing offline_access restarts the authorisation without it, once. + state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0] + retry = parse_qs(urlparse(auth.without_offline(state)).query) + assert retry['scope'] == ['openid'] and retry['state'][0] != state + try: + auth.without_offline(state) + raise AssertionError('the refused state must be spent') + except TinyError: + pass + # A session grant close to its end is flagged while renewals are not happening. + server['mode'] = 'session' + auth.complete('good-code', retry['state'][0]) + with connect() as c: + c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=now()+interval '2 hours' WHERE provider='tiny'") + status = auth.status() + assert status['connected'] and status['problem'] == 'expiring' and not status['offline'] + print('PASS: a refused offline scope falls back once; a connection near its end is flagged') + + # Tiny's redirect back with an error instead of a code. + from app.api.operator import tiny_callback + declined = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0] + assert tiny_callback('', declined, 'access_denied').headers['location'] == '/?tiny=failed' + assert tiny_callback('', '', '').headers['location'] == '/?tiny=failed' + scoped = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0] + location = urlparse(tiny_callback('', scoped, 'invalid_scope').headers['location']) + assert location.netloc == 'accounts.tiny.com.br' and parse_qs(location.query)['scope'] == ['openid'] + assert tiny_callback('', scoped, 'invalid_scope').headers['location'] == '/?tiny=failed' + print('PASS: a declined or malformed callback returns to the Kanban; a refused scope retries without it') + if __name__ == '__main__': run() diff --git a/web/kanban.js b/web/kanban.js index bcd26b2..a1decf7 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -483,8 +483,21 @@ if(tinyResult)history.replaceState(null,'',location.pathname); function tinyHeader(){ const t=board.tiny||{};const el=$('tiny'); if(!t.configured){el.textContent='';return;} - el.textContent=t.connected?'Tiny conectado':'Tiny não conectado'; - el.style.setProperty('--dot',t.connected?'var(--fin)':'var(--warn)'); + const trouble=!t.connected||t.problem; + el.textContent=!t.connected?'Tiny não conectado':t.problem?'Tiny: verificar conexão':'Tiny conectado'; + el.style.setProperty('--dot',trouble?'var(--warn)':'var(--fin)'); +} +const TINY_PROBLEMS={refused:'O Tiny recusou a renovação da conexão. Conecte de novo.', + expired:'A conexão com o Tiny expirou. Conecte de novo.', + 'renewal-failing':'A última renovação falhou; o sistema tenta de novo sozinho.', + expiring:'A conexão expira em breve e não está sendo renovada.'}; +function tinyDetail(t){ + if(!t.connected_by)return 'Conecte com uma conta do Tiny.'; + const parts=[t.connected_by+' · conectado em '+when(t.connected_at),'renovado em '+when(t.renewed_at), + t.offline?'sem expiração diária':t.expires_at?'válido até '+when(t.expires_at):'', + 'envio de pedidos '+(t.orders_enabled?'ativo':'desativado')].filter(Boolean); + const problem=TINY_PROBLEMS[t.problem]; + return (problem?problem+(t.failed_at&&t.problem!=='expired'?' (falha em '+when(t.failed_at)+')':'')+' · ':'')+parts.join(' · '); } function integration(name,state,tone,text,actions=[]){ const box=node('div',undefined,'integration'); @@ -633,9 +646,8 @@ function renderIntegrations(){ if(t.connected){ actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{}); say(r.ok?'Conexão com o Tiny ok: pedidos, contatos e os 4 produtos conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));} - cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn', - t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'): - 'Conecte com uma conta do Tiny.',actions)); + cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado', + t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions)); } const mp=board.providers?.payment; cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off',