assinafy 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.en.md +64 -21
- data/README.md +63 -18
- data/docs/API_REFERENCE.md +36 -13
- data/lib/assinafy/client.rb +4 -0
- data/lib/assinafy/oauth.rb +13 -6
- data/lib/assinafy/resources/base_resource.rb +36 -5
- data/lib/assinafy/resources/document_resource.rb +3 -2
- data/lib/assinafy/resources/oauth_resource.rb +93 -49
- data/lib/assinafy/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c202496157dd16d5ae51e1e6416ca30ab0d6ec3665ffa6dead88219214c82b8
|
|
4
|
+
data.tar.gz: 9c07ecf32a3c9e9e94d964d56f6f38dc8c670d2cc011059d4c14409e79972b9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4bd51928265eb56100d62653932be2145442efb23e4708007070a027aeaff0e96678014c4d2a5b39ca2a9dcb37c6a13e5caf3b16798e28b6ad2ad535ef937068
|
|
7
|
+
data.tar.gz: 7c1c6a6f07f0794b685b414195161f9832c74e9e196adff5f8713b26b44d7b94c188651184144af0b7393ad8bfea97d601a0e350c7165d2a4709356bcda0743a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `assinafy` Ruby gem are documented here.
|
|
4
4
|
|
|
5
|
+
## 1.9.0
|
|
6
|
+
|
|
7
|
+
- `OAuthResource#refresh`, and `#token` with the `refresh_token` grant, raise `Assinafy::Error` instead of
|
|
8
|
+
returning a success whose `refresh_token` is missing, blank, or the one sent. The token sent may already be
|
|
9
|
+
retired, so handle the error like `invalid_grant`.
|
|
10
|
+
- `OAuthResource` posts `/oauth/token` and `/oauth/revoke` bodies as `application/x-www-form-urlencoded`, as
|
|
11
|
+
RFC 6749 and RFC 7009 define them. The API accepts this and JSON alike; no caller change is needed.
|
|
12
|
+
- Every `ApiError`, not only `OAuthError`, carries the `WWW-Authenticate` challenge in
|
|
13
|
+
`context[:www_authenticate]`, including one raised for an error status inside a `2xx` envelope, so a `403` for
|
|
14
|
+
a missing OAuth scope can be told apart from one for another workspace or role.
|
|
15
|
+
- `DocumentResource#verify` documents the nullable `agreement_code` field, the agreement code printed on the
|
|
16
|
+
document certificate. `spec/fixtures/api_contract.json` tracks the current contract.
|
|
17
|
+
- `scripts/check_api_contract.rb` requires TLS 1.2 or newer.
|
|
18
|
+
- The OAuth guides cover refresh-token rotation (every refresh returns a new refresh token valid for 30 days and
|
|
19
|
+
retires the old one): storing the new tokens and rebuilding the client, never resending a refresh token after
|
|
20
|
+
an ambiguous failure, and revoking the refresh token currently stored on disconnect. They also cover checking
|
|
21
|
+
`state` and `iss` against the values stored for each authorization attempt (the sandbox has its own issuer)
|
|
22
|
+
and finding the connected workspace through `GET /accounts`.
|
|
23
|
+
|
|
5
24
|
## 1.8.1
|
|
6
25
|
|
|
7
26
|
- The SDK's own HTTPS client now requires TLS 1.2 or newer; TLS 1.0 and 1.1 are refused. Caller-supplied clients are unchanged.
|
data/README.en.md
CHANGED
|
@@ -154,7 +154,7 @@ The flow is authorization-code with **mandatory PKCE**; the authorization server
|
|
|
154
154
|
| `email` | Include the user's email and verification status in the claims |
|
|
155
155
|
| `offline_access` | Be issued a refresh token |
|
|
156
156
|
|
|
157
|
-
**1. Redirect the user.** Store the verifier
|
|
157
|
+
**1. Redirect the user.** Store the verifier, the state, and the expected issuer in the session first.
|
|
158
158
|
|
|
159
159
|
```ruby
|
|
160
160
|
verifier = Assinafy::OAuth.generate_code_verifier
|
|
@@ -162,6 +162,7 @@ state = Assinafy::OAuth.generate_state
|
|
|
162
162
|
|
|
163
163
|
session[:assinafy_code_verifier] = verifier
|
|
164
164
|
session[:assinafy_state] = state
|
|
165
|
+
session[:assinafy_issuer] = Assinafy::OAuth::AUTHORIZATION_SERVER # issuer of the server this attempt uses
|
|
165
166
|
|
|
166
167
|
redirect_to Assinafy::OAuth.authorization_url(
|
|
167
168
|
client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
|
|
@@ -173,11 +174,20 @@ redirect_to Assinafy::OAuth.authorization_url(
|
|
|
173
174
|
```
|
|
174
175
|
|
|
175
176
|
The `code_challenge` is derived from the verifier; the verifier itself never appears in the URL.
|
|
177
|
+
`Assinafy::OAuth::AUTHORIZATION_SERVER` is the production issuer. The sandbox has its own: pass
|
|
178
|
+
`authorization_endpoint: 'https://auth-sandbox.assinafy.com.br/oauth/authorize'` and store
|
|
179
|
+
`https://auth-sandbox.assinafy.com.br` instead.
|
|
176
180
|
|
|
177
|
-
**2. Exchange the code.**
|
|
181
|
+
**2. Exchange the code.** Check `state` and `iss` against the values stored for this attempt before anything
|
|
182
|
+
else, including on an `error=` return — that is the CSRF check, and it rejects responses that are not yours. The
|
|
183
|
+
code is single-use and expires 60 seconds after approval: exchange it at once, and never retry.
|
|
178
184
|
|
|
179
185
|
```ruby
|
|
180
|
-
|
|
186
|
+
unless params[:state] == session.delete(:assinafy_state) &&
|
|
187
|
+
params[:iss] == session.delete(:assinafy_issuer)
|
|
188
|
+
raise 'authorization response is not ours'
|
|
189
|
+
end
|
|
190
|
+
raise "authorization not granted: #{params[:error]}" if params[:error] # access_denied, invalid_scope, ...
|
|
181
191
|
|
|
182
192
|
tokens = Assinafy::Client.new.oauth.exchange_code(
|
|
183
193
|
code: params.fetch(:code),
|
|
@@ -189,34 +199,63 @@ tokens = Assinafy::Client.new.oauth.exchange_code(
|
|
|
189
199
|
# 'refresh_token' => ..., 'scope' => 'documents:read documents:write' }
|
|
190
200
|
```
|
|
191
201
|
|
|
192
|
-
**3. Act as the user.**
|
|
202
|
+
**3. Act as the user.** The token works only in the workspace the user picked; store its id next to the tokens.
|
|
193
203
|
|
|
194
204
|
```ruby
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID')
|
|
198
|
-
)
|
|
205
|
+
access_token = tokens.fetch('access_token')
|
|
206
|
+
workspace_id = Assinafy::Client.new(token: access_token).accounts.list[:data].first.fetch('id')
|
|
199
207
|
|
|
208
|
+
user_client = Assinafy::Client.new(token: access_token, account_id: workspace_id)
|
|
200
209
|
user_client.documents.list
|
|
201
210
|
user_client.oauth.userinfo # => { 'sub' => ..., 'name' => ..., 'email' => ... }
|
|
202
211
|
```
|
|
203
212
|
|
|
204
|
-
**Refresh and revoke.**
|
|
213
|
+
**Refresh and revoke.** `connection` below is your stored record for this user: tokens, expiry, workspace id.
|
|
205
214
|
|
|
206
215
|
```ruby
|
|
207
|
-
|
|
216
|
+
tokens = Assinafy::Client.new.oauth.refresh(
|
|
217
|
+
refresh_token: connection.refresh_token,
|
|
218
|
+
client_id: ENV.fetch('ASSINAFY_CLIENT_ID')
|
|
219
|
+
)
|
|
208
220
|
|
|
209
|
-
|
|
210
|
-
|
|
221
|
+
# The refresh token just sent is retired: store the new tokens before anything else.
|
|
222
|
+
connection.update!(
|
|
223
|
+
refresh_token: tokens.fetch('refresh_token'),
|
|
224
|
+
access_token: tokens.fetch('access_token'),
|
|
225
|
+
expires_at: Time.now + tokens.fetch('expires_in')
|
|
211
226
|
)
|
|
227
|
+
user_client = Assinafy::Client.new(token: connection.access_token, account_id: connection.workspace_id)
|
|
212
228
|
```
|
|
213
229
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
already-revoked token — so the endpoint cannot be used to probe whether a token exists.
|
|
230
|
+
When a user disconnects, revoke the refresh token in storage **now** — the latest one — then delete the stored
|
|
231
|
+
tokens:
|
|
217
232
|
|
|
218
|
-
|
|
219
|
-
|
|
233
|
+
```ruby
|
|
234
|
+
Assinafy::Client.new.oauth.revoke(
|
|
235
|
+
token: connection.reload.refresh_token,
|
|
236
|
+
client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
|
|
237
|
+
token_type_hint: 'refresh_token'
|
|
238
|
+
)
|
|
239
|
+
connection.destroy!
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
A refresh token exists only when `offline_access` was requested *and* consented. Revoking a refresh token also
|
|
243
|
+
invalidates the access tokens issued from it. Every revoke outcome returns `200` — including an unknown,
|
|
244
|
+
already-revoked, or rotated token — so the endpoint cannot be used to probe whether a token exists, and revoking
|
|
245
|
+
a stale copy can look successful while the connection stays active.
|
|
246
|
+
|
|
247
|
+
> The SDK does **not** refresh automatically. Persist `expires_in` and refresh before expiry. Every refresh
|
|
248
|
+
> returns a **new** refresh token, valid for another 30 days, and retires the old one, so a connection only
|
|
249
|
+
> expires after 30 days without a refresh. Reusing a retired refresh token ends the whole connection: store the
|
|
250
|
+
> new tokens before using them, and refresh one at a time per connection. `refresh` raises `Assinafy::Error`
|
|
251
|
+
> rather than return a success without a new refresh token; handle it like `invalid_grant`.
|
|
252
|
+
>
|
|
253
|
+
> The SDK sends each token request once. If a refresh fails without a clear answer — a timeout, a reset
|
|
254
|
+
> connection, a `5xx` — the server may have rotated the token without the response reaching you. Re-read the
|
|
255
|
+
> stored refresh token: if it is still the one you sent, **never send it again**; ask the user to connect again.
|
|
256
|
+
> Carry on only if another worker has since stored a different one. Only a failure that provably happened before
|
|
257
|
+
> the request was sent — DNS resolution, a refused connection, a failed TLS handshake — is safe to retry. On an
|
|
258
|
+
> API `401`, refresh once; if that fails, or on `invalid_grant`, ask the user to connect again.
|
|
220
259
|
|
|
221
260
|
**Discovery**, instead of hardcoding endpoints:
|
|
222
261
|
|
|
@@ -241,6 +280,10 @@ rescue Assinafy::OAuthError => e
|
|
|
241
280
|
e.context[:www_authenticate] # on a 403, names the missing scope
|
|
242
281
|
```
|
|
243
282
|
|
|
283
|
+
Any resource, not only these routes, puts that challenge in `context[:www_authenticate]`. Treat it as a prompt to
|
|
284
|
+
reconnect with that scope added, not as a retry; a `403` without it means another workspace, the user's role, or
|
|
285
|
+
an area OAuth tokens never reach.
|
|
286
|
+
|
|
244
287
|
### Accounts
|
|
245
288
|
|
|
246
289
|
```ruby
|
|
@@ -760,11 +803,11 @@ result[:signer_ids] # => ['<sid-1>', '<sid-2>']
|
|
|
760
803
|
The SDK raises one of:
|
|
761
804
|
|
|
762
805
|
- `Assinafy::ValidationError` — caller-side input invalid (missing IDs, bad email, etc.).
|
|
763
|
-
- `Assinafy::ApiError` — the API returned a non-2xx status. Includes `status_code` and `response_data
|
|
806
|
+
- `Assinafy::ApiError` — the API returned a non-2xx status. Includes `status_code` and `response_data`; on a
|
|
807
|
+
`403`, `context[:www_authenticate]` names the scope an OAuth token is missing.
|
|
764
808
|
- `Assinafy::OAuthError` — an OAuth endpoint failed. Subclasses `ApiError`, and adds `error` and
|
|
765
|
-
`error_description` from the flat RFC 6749 body
|
|
766
|
-
|
|
767
|
-
- `Assinafy::NetworkError` — Faraday connection error or timeout.
|
|
809
|
+
`error_description` from the flat RFC 6749 body.
|
|
810
|
+
- `Assinafy::NetworkError` — Faraday connection error, timeout, or TLS failure.
|
|
768
811
|
- `Assinafy::Error` — base class; other unexpected errors get wrapped here with the operation label.
|
|
769
812
|
|
|
770
813
|
All inherit a `#context` Hash with debugging metadata.
|
data/README.md
CHANGED
|
@@ -411,8 +411,9 @@ credenciais ou superfícies administrativas — independentemente do escopo.
|
|
|
411
411
|
verificador = Assinafy::OAuth.generate_code_verifier
|
|
412
412
|
state = Assinafy::OAuth.generate_state
|
|
413
413
|
|
|
414
|
-
session[:assinafy_code_verifier] = verificador # guarde os
|
|
414
|
+
session[:assinafy_code_verifier] = verificador # guarde os três na sessão
|
|
415
415
|
session[:assinafy_state] = state
|
|
416
|
+
session[:assinafy_issuer] = Assinafy::OAuth::AUTHORIZATION_SERVER # emissor do servidor usado nesta tentativa
|
|
416
417
|
|
|
417
418
|
redirect_to Assinafy::OAuth.authorization_url(
|
|
418
419
|
client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
|
|
@@ -424,11 +425,18 @@ redirect_to Assinafy::OAuth.authorization_url(
|
|
|
424
425
|
```
|
|
425
426
|
|
|
426
427
|
O `code_challenge` é derivado do verificador (`S256`) — o verificador em si nunca vai para a URL.
|
|
428
|
+
`Assinafy::OAuth::AUTHORIZATION_SERVER` é o emissor de produção. O sandbox tem o seu: passe
|
|
429
|
+
`authorization_endpoint: 'https://auth-sandbox.assinafy.com.br/oauth/authorize'` e guarde
|
|
430
|
+
`https://auth-sandbox.assinafy.com.br`.
|
|
427
431
|
|
|
428
432
|
### Passo 2 — trocar o código por tokens
|
|
429
433
|
|
|
430
434
|
```ruby
|
|
431
|
-
|
|
435
|
+
unless params[:state] == session.delete(:assinafy_state) &&
|
|
436
|
+
params[:iss] == session.delete(:assinafy_issuer)
|
|
437
|
+
raise 'resposta de autorização inválida'
|
|
438
|
+
end
|
|
439
|
+
raise "autorização não concedida: #{params[:error]}" if params[:error] # access_denied, invalid_scope, ...
|
|
432
440
|
|
|
433
441
|
tokens = Assinafy::Client.new.oauth.exchange_code(
|
|
434
442
|
code: params.fetch(:code),
|
|
@@ -443,41 +451,74 @@ tokens['refresh_token'] # => presente apenas com offline_access
|
|
|
443
451
|
tokens['scope'] # => "documents:read documents:write"
|
|
444
452
|
```
|
|
445
453
|
|
|
446
|
-
|
|
454
|
+
Confira `state` e `iss` contra os valores guardados nesta tentativa **antes de qualquer outra
|
|
455
|
+
coisa**, inclusive num retorno com `error=` — é a proteção contra CSRF e contra respostas que não
|
|
456
|
+
são suas. O código vale uma vez e expira 60
|
|
457
|
+
segundos depois da aprovação: troque-o na hora, sem repetir.
|
|
447
458
|
|
|
448
459
|
### Passo 3 — agir como o usuário
|
|
449
460
|
|
|
450
461
|
```ruby
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
)
|
|
462
|
+
access_token = tokens.fetch('access_token')
|
|
463
|
+
|
|
464
|
+
# O token só vale na workspace que o usuário escolheu; guarde o id junto dos tokens.
|
|
465
|
+
workspace_id = Assinafy::Client.new(token: access_token).accounts.list[:data].first.fetch('id')
|
|
455
466
|
|
|
467
|
+
usuario = Assinafy::Client.new(token: access_token, account_id: workspace_id)
|
|
456
468
|
usuario.documents.list
|
|
457
469
|
usuario.oauth.userinfo # => { 'sub' => ..., 'name' => ..., 'email' => ... }
|
|
458
470
|
```
|
|
459
471
|
|
|
460
472
|
### Renovar e revogar
|
|
461
473
|
|
|
474
|
+
`conexao` abaixo é o registro que você guarda para o usuário: tokens, validade e id da workspace.
|
|
475
|
+
|
|
462
476
|
```ruby
|
|
463
|
-
novos =
|
|
464
|
-
refresh_token:
|
|
477
|
+
novos = Assinafy::Client.new.oauth.refresh(
|
|
478
|
+
refresh_token: conexao.refresh_token,
|
|
465
479
|
client_id: ENV.fetch('ASSINAFY_CLIENT_ID')
|
|
466
480
|
)
|
|
467
481
|
|
|
468
|
-
|
|
469
|
-
|
|
482
|
+
# O refresh token enviado foi aposentado: guarde os tokens novos antes de qualquer outra coisa.
|
|
483
|
+
conexao.update!(
|
|
484
|
+
refresh_token: novos.fetch('refresh_token'),
|
|
485
|
+
access_token: novos.fetch('access_token'),
|
|
486
|
+
expires_at: Time.now + novos.fetch('expires_in')
|
|
487
|
+
)
|
|
488
|
+
usuario = Assinafy::Client.new(token: conexao.access_token, account_id: conexao.workspace_id)
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Quando o usuário desconectar, revogue o refresh token guardado **agora** — o mais recente — e só
|
|
492
|
+
depois apague os tokens:
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
Assinafy::Client.new.oauth.revoke(
|
|
496
|
+
token: conexao.reload.refresh_token,
|
|
470
497
|
client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
|
|
471
498
|
token_type_hint: 'refresh_token'
|
|
472
499
|
)
|
|
500
|
+
conexao.destroy!
|
|
473
501
|
```
|
|
474
502
|
|
|
475
503
|
Sem `offline_access` não há refresh token: quando o access token expirar, mande o usuário pelo
|
|
476
504
|
fluxo de autorização de novo. Revogar um refresh token invalida também os access tokens emitidos
|
|
477
|
-
a partir dele.
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
>
|
|
505
|
+
a partir dele. A revogação responde `200` inclusive para um token já aposentado, então revogar uma
|
|
506
|
+
cópia antiga parece dar certo enquanto a conexão continua ativa.
|
|
507
|
+
|
|
508
|
+
> O SDK **não renova o token automaticamente**. Guarde `expires_in` e renove antes de expirar.
|
|
509
|
+
> Cada renovação devolve um refresh token **novo**, válido por mais 30 dias, e aposenta o
|
|
510
|
+
> anterior: a conexão só expira se passar 30 dias sem renovar. Reutilizar um refresh token
|
|
511
|
+
> aposentado encerra a conexão inteira, então guarde os tokens novos antes de usá-los e faça uma
|
|
512
|
+
> renovação por vez por conexão. `refresh` levanta `Assinafy::Error` em vez de devolver um
|
|
513
|
+
> sucesso sem refresh token novo; trate como `invalid_grant`.
|
|
514
|
+
>
|
|
515
|
+
> O SDK envia cada pedido de token uma única vez. Se a renovação falhar sem resposta clara —
|
|
516
|
+
> timeout, conexão interrompida, `5xx` —, o servidor pode ter trocado o token sem que a resposta
|
|
517
|
+
> chegasse. Releia o refresh token guardado: se ainda for o que você enviou, **nunca o reenvie**;
|
|
518
|
+
> peça ao usuário para conectar de novo. Siga em frente só se outro processo já tiver guardado um
|
|
519
|
+
> token diferente. Só é seguro repetir uma falha que comprovadamente aconteceu antes do envio:
|
|
520
|
+
> DNS, conexão recusada, handshake TLS. Num `401` da API, renove uma vez; se falhar, ou em
|
|
521
|
+
> `invalid_grant`, peça ao usuário para conectar de novo.
|
|
481
522
|
|
|
482
523
|
### Descoberta
|
|
483
524
|
|
|
@@ -507,7 +548,10 @@ rescue Assinafy::OAuthError => e
|
|
|
507
548
|
end
|
|
508
549
|
```
|
|
509
550
|
|
|
510
|
-
Num `403
|
|
551
|
+
Num `403` de qualquer recurso, `e.context[:www_authenticate]` traz o desafio que **nomeia o escopo
|
|
552
|
+
faltante**: peça ao usuário para conectar de novo incluindo esse escopo, sem repetir a chamada. Um
|
|
553
|
+
`403` sem esse desafio indica outra workspace, o papel do usuário ou uma área que tokens OAuth não
|
|
554
|
+
alcançam.
|
|
511
555
|
|
|
512
556
|
---
|
|
513
557
|
|
|
@@ -786,8 +830,9 @@ cadeia de certificação.
|
|
|
786
830
|
| Sandbox | `https://sandbox.assinafy.com.br/v1` |
|
|
787
831
|
|
|
788
832
|
O sandbox é gratuito e espelha a produção 1 para 1 — mesmas rotas, mesmos contratos, incluindo
|
|
789
|
-
OAuth 2.1 e certificado digital. Troque apenas a `base_url`
|
|
790
|
-
|
|
833
|
+
OAuth 2.1 e certificado digital. Troque apenas a `base_url` — e, no OAuth, o servidor de
|
|
834
|
+
autorização (`https://auth-sandbox.assinafy.com.br`) — para testar a integração de ponta a ponta
|
|
835
|
+
antes de ir para produção.
|
|
791
836
|
|
|
792
837
|
---
|
|
793
838
|
|
data/docs/API_REFERENCE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Assinafy Ruby SDK API Reference
|
|
2
2
|
|
|
3
|
-
> Contract source: Assinafy API v1 OpenAPI 3.0.0, retrieved 2026-
|
|
4
|
-
> `https://api.assinafy.com.br/v1/docs/openapi.json` (
|
|
3
|
+
> Contract source: Assinafy API v1 OpenAPI 3.0.0, retrieved 2026-09-25 from
|
|
4
|
+
> `https://api.assinafy.com.br/v1/docs/openapi.json` (71 paths, 93 operations, 39 schemas).
|
|
5
5
|
> `scripts/check_api_contract.rb` validates contract compatibility weekly.
|
|
6
6
|
|
|
7
7
|
This is the SDK-facing contract reference. Paths below are wire paths; Ruby methods return the unwrapped
|
|
@@ -21,12 +21,15 @@ Required parameters and object properties are marked with `*`.
|
|
|
21
21
|
`client.oauth` covers the token lifecycle. A token is scoped to one workspace and carries only the scopes the
|
|
22
22
|
user approved, and it never reaches billing, account lifecycle, credential management, or admin surfaces
|
|
23
23
|
regardless of scope. A missing scope answers `403` with a `WWW-Authenticate` challenge naming it, which the
|
|
24
|
-
SDK exposes as `
|
|
25
|
-
|
|
24
|
+
SDK exposes as `ApiError#context[:www_authenticate]` on every resource: reconnect with that scope added rather
|
|
25
|
+
than retrying. Store the `code_verifier`, `state`, and expected issuer with each authorization attempt — the
|
|
26
|
+
issuer of the authorization server it uses: `https://auth.assinafy.com.br` in production,
|
|
27
|
+
`https://auth-sandbox.assinafy.com.br` in the sandbox. Check `state` and `iss` against those stored values on
|
|
28
|
+
the callback before anything else, including an `error=` return, and keep refresh tokens out of logs.
|
|
26
29
|
- Signer-facing operations use the one-time `signer-access-code` query parameter where shown. Never log, commit,
|
|
27
30
|
or place API keys, bearer tokens, signer codes, or real recipient addresses in examples or fixtures.
|
|
28
|
-
- HTTP connections use Ruby/Faraday TLS verification and the host system trust store;
|
|
29
|
-
upstream TLS certificate.
|
|
31
|
+
- HTTP connections require TLS 1.2 or newer and use Ruby/Faraday TLS verification and the host system trust store;
|
|
32
|
+
the SDK does not pin the upstream TLS certificate.
|
|
30
33
|
- `DocumentResource#verify` reports Assinafy upstream verification data. It does not independently validate a PDF
|
|
31
34
|
signature, certificate chain, OCSP/CRL status, or legal validity. The API artifact name `certificated` is not an
|
|
32
35
|
additional local guarantee.
|
|
@@ -103,8 +106,8 @@ Required parameters and object properties are marked with `*`.
|
|
|
103
106
|
| GET | `/v1/documents/{documentSignatureHash}/verify` | `DocumentResource#verify` | Public | path `documentSignatureHash*`: string | None | `200` `application/json` [`Envelope`](#envelope) plus object { `data`: [`DocumentVerification`](#documentverification) } |
|
|
104
107
|
| GET | `/v1/field-types` | `FieldResource#types` | Bearer token or `X-Api-Key` | None | None | `200` `application/json` [`Envelope`](#envelope) plus object { `data`: Array<[`FieldType`](#fieldtype)> } |
|
|
105
108
|
| POST | `/v1/login` | `AuthResource#login` | Public | None | required; `application/json` object { `email*`: string (email); `password*`: string (password) } | `200` `application/json` [`Envelope`](#envelope) plus object { `data`: [`AuthSession`](#authsession) } |
|
|
106
|
-
| POST | `/v1/oauth/token` | `OAuthResource#token`, `#exchange_code`, `#refresh` | Public (client authenticates with `client_id` in the body) | None | required; `application/json` object { `grant_type*`: string enum `authorization_code`, `refresh_token`; `client_id*`: string; `code`: string; `redirect_uri`: string (uri); `code_verifier`: string; `refresh_token`: string; `client_secret`: string; `resource`: string (uri) } | `200` `application/json` **flat** object { `access_token`: string; `token_type`: string; `expires_in`: integer; `refresh_token`: string (nullable); `scope`: string; `id_token`: string (nullable) } — not enveloped |
|
|
107
|
-
| POST | `/v1/oauth/revoke` | `OAuthResource#revoke` | Public (client authenticates with `client_id` in the body) | None | required; `application/json` object { `token*`: string; `client_id*`: string; `token_type_hint`: string enum `access_token`, `refresh_token`; `client_secret`: string } | `200` empty body — every token outcome reports success |
|
|
109
|
+
| POST | `/v1/oauth/token` | `OAuthResource#token`, `#exchange_code`, `#refresh` | Public (client authenticates with `client_id` in the body) | None | required; `application/x-www-form-urlencoded` (RFC 6749; the contract lists `application/json`) object { `grant_type*`: string enum `authorization_code`, `refresh_token`; `client_id*`: string; `code`: string; `redirect_uri`: string (uri); `code_verifier`: string; `refresh_token`: string; `client_secret`: string; `resource`: string (uri) } | `200` `application/json` **flat** object { `access_token`: string; `token_type`: string; `expires_in`: integer; `refresh_token`: string (nullable); `scope`: string; `id_token`: string (nullable) } — not enveloped |
|
|
110
|
+
| POST | `/v1/oauth/revoke` | `OAuthResource#revoke` | Public (client authenticates with `client_id` in the body) | None | required; `application/x-www-form-urlencoded` (RFC 7009; the contract lists `application/json`) object { `token*`: string; `client_id*`: string; `token_type_hint`: string enum `access_token`, `refresh_token`; `client_secret`: string } | `200` empty body — every token outcome reports success |
|
|
108
111
|
| GET | `/v1/oauth/userinfo` | `OAuthResource#userinfo` | Bearer token or `X-Api-Key`; requires the `openid` scope | None | None | `200` `application/json` **flat** object { `sub`: string; `name`: string (nullable); `email`: string (email, nullable); `email_verified`: boolean (nullable) } — not enveloped |
|
|
109
112
|
| GET | `/v1/public/documents/{documentId}` | `DocumentResource#public_info` | Public | path `documentId*`: string | None | `200` `application/json` [`Envelope`](#envelope) plus object { `data`: [`Document`](#document) } |
|
|
110
113
|
| PUT | `/v1/public/documents/{documentId}/send-token` | `DocumentResource#send_token` | Public | path `documentId*`: string | optional; `application/json` object { `email`: string (email) } | `200` `application/json` [`Envelope`](#envelope) |
|
|
@@ -167,7 +170,8 @@ raise `Assinafy::ApiError`:
|
|
|
167
170
|
- `message` uses the API's `message`, `error`, or `name` value.
|
|
168
171
|
- `response_data` preserves the parsed response body.
|
|
169
172
|
- `error_name` and `error_code` expose framework `name` and `code` values when present.
|
|
170
|
-
- `context` contains `status_code` and `response_data` for structured logging or support diagnostics
|
|
173
|
+
- `context` contains `status_code` and `response_data` for structured logging or support diagnostics, plus
|
|
174
|
+
`www_authenticate` when the response carries that header.
|
|
171
175
|
|
|
172
176
|
Transport, timeout, and TLS failures raise `Assinafy::NetworkError`; caller-side validation failures raise
|
|
173
177
|
`Assinafy::ValidationError` before a request is sent.
|
|
@@ -228,10 +232,28 @@ that `base_url` carries.
|
|
|
228
232
|
|
|
229
233
|
### Token refresh
|
|
230
234
|
|
|
231
|
-
The SDK does not refresh access tokens automatically. Persist `expires_in` alongside the token
|
|
232
|
-
call `OAuthResource#refresh` before expiry
|
|
233
|
-
|
|
234
|
-
|
|
235
|
+
The SDK does not refresh access tokens automatically. Persist `expires_in` alongside the token and
|
|
236
|
+
call `OAuthResource#refresh` before expiry. A refresh token is issued only when `offline_access` was
|
|
237
|
+
both requested and consented.
|
|
238
|
+
|
|
239
|
+
Every refresh returns a new refresh token, valid for another 30 days, and retires the one sent, so a
|
|
240
|
+
connection only expires after 30 days without a refresh. Reusing a retired refresh token ends the
|
|
241
|
+
whole connection. Store the new refresh and access tokens before using either, rebuild the client with
|
|
242
|
+
the new access token, and run one refresh at a time per connection. `OAuthResource#refresh`, and
|
|
243
|
+
`#token` with the `refresh_token` grant, raise `Assinafy::Error` rather than return a success whose
|
|
244
|
+
`refresh_token` is missing, blank, or the one sent; handle that like `invalid_grant`.
|
|
245
|
+
|
|
246
|
+
The SDK never retries token requests; do not add middleware that does. After an ambiguous failure — a
|
|
247
|
+
timeout, a reset connection, a `5xx` — the server may have rotated the token without the response
|
|
248
|
+
arriving. Re-read the stored refresh token: if it is still the one you sent, never send it again; ask
|
|
249
|
+
the user to connect again. Proceed only if another worker has since stored a different one. Only a
|
|
250
|
+
failure that provably happened before the request was sent (DNS resolution, a refused connection, a
|
|
251
|
+
failed TLS handshake) is safe to retry. On an API `401`, refresh once; if that fails, or on
|
|
252
|
+
`invalid_grant`, ask the user to connect again.
|
|
253
|
+
|
|
254
|
+
When a user disconnects, revoke the refresh token in storage at that moment with `OAuthResource#revoke`,
|
|
255
|
+
then delete the stored tokens. Revoking a rotated token also answers `200`, so revoking a stale copy can
|
|
256
|
+
look successful while the connection stays active.
|
|
235
257
|
|
|
236
258
|
### Digital-certificate signing
|
|
237
259
|
|
|
@@ -530,6 +552,7 @@ The verification result for a document looked up by signature hash. When not ver
|
|
|
530
552
|
| --- | --- | --- | --- | --- |
|
|
531
553
|
| `hash` | string | No | No | — |
|
|
532
554
|
| `id` | string | No | Yes | — |
|
|
555
|
+
| `agreement_code` | string | No | Yes | Agreement code printed on the document certificate. |
|
|
533
556
|
| `status` | string | No | Yes | — |
|
|
534
557
|
| `page_count` | string | No | Yes | — |
|
|
535
558
|
| `signer_count` | string | No | Yes | — |
|
data/lib/assinafy/client.rb
CHANGED
|
@@ -241,6 +241,10 @@ module Assinafy
|
|
|
241
241
|
# Expose the underlying Faraday connection (for advanced use cases,
|
|
242
242
|
# such as adding middleware or inspecting headers in tests).
|
|
243
243
|
#
|
|
244
|
+
# OAuth token requests share this connection: never add middleware that
|
|
245
|
+
# retries `POST /oauth/token`. A retried refresh resends a refresh token the
|
|
246
|
+
# first attempt may already have retired, which ends the user's connection.
|
|
247
|
+
#
|
|
244
248
|
# @return [Faraday::Connection]
|
|
245
249
|
#
|
|
246
250
|
# @example Inspect the auth header the SDK sends
|
data/lib/assinafy/oauth.rb
CHANGED
|
@@ -15,11 +15,13 @@ module Assinafy
|
|
|
15
15
|
# helpers exist so an integration never hand-rolls the S256 transform.
|
|
16
16
|
#
|
|
17
17
|
# @example Full authorization-code flow with PKCE
|
|
18
|
-
# # 1. Before redirecting, mint and store a verifier for this
|
|
18
|
+
# # 1. Before redirecting, mint and store a verifier and state for this
|
|
19
|
+
# # attempt, with the issuer of the authorization server it uses.
|
|
19
20
|
# verifier = Assinafy::OAuth.generate_code_verifier
|
|
20
21
|
# state = Assinafy::OAuth.generate_state
|
|
21
22
|
# session[:assinafy_code_verifier] = verifier
|
|
22
23
|
# session[:assinafy_state] = state
|
|
24
|
+
# session[:assinafy_issuer] = Assinafy::OAuth::AUTHORIZATION_SERVER
|
|
23
25
|
#
|
|
24
26
|
# # 2. Send the user to the authorization server.
|
|
25
27
|
# redirect_to Assinafy::OAuth.authorization_url(
|
|
@@ -34,13 +36,17 @@ module Assinafy
|
|
|
34
36
|
# # &scope=documents%3Aread+documents%3Awrite+offline_access&state=...
|
|
35
37
|
# # &code_challenge=...&code_challenge_method=S256"
|
|
36
38
|
#
|
|
37
|
-
# # 3. On the callback,
|
|
38
|
-
# #
|
|
39
|
+
# # 3. On the callback, before anything else (an `error=` return
|
|
40
|
+
# # included), check `state` and `iss` against the values stored for
|
|
41
|
+
# # this attempt; stop if either differs. Then exchange the code (see
|
|
42
|
+
# # Resources::OAuthResource#exchange_code).
|
|
39
43
|
#
|
|
40
44
|
# @see https://api.assinafy.com.br/v1/docs
|
|
41
45
|
module OAuth
|
|
42
|
-
#
|
|
43
|
-
# `GET /.well-known/oauth-protected-resource` as
|
|
46
|
+
# Production authorization server, which owns the browser-facing flow.
|
|
47
|
+
# Published by `GET /.well-known/oauth-protected-resource` as
|
|
48
|
+
# `authorization_servers[0]`, and the `iss` its callbacks carry. The
|
|
49
|
+
# sandbox publishes its own, `https://auth-sandbox.assinafy.com.br`.
|
|
44
50
|
AUTHORIZATION_SERVER = 'https://auth.assinafy.com.br'
|
|
45
51
|
|
|
46
52
|
# RFC 8414 discovery document for {AUTHORIZATION_SERVER}.
|
|
@@ -151,7 +157,8 @@ module Assinafy
|
|
|
151
157
|
# @param resource [String, nil] RFC 8707 resource indicator; when sent
|
|
152
158
|
# here it must also be sent to the token endpoint, and must be the
|
|
153
159
|
# `resource` published by `/.well-known/oauth-protected-resource`
|
|
154
|
-
# @param authorization_endpoint [String] override for a non-default
|
|
160
|
+
# @param authorization_endpoint [String] override for a non-default
|
|
161
|
+
# server; store that server's issuer for the callback's `iss` check
|
|
155
162
|
# @param extra_params [Hash] additional query parameters, merged last
|
|
156
163
|
# @return [String] the absolute URL to redirect to
|
|
157
164
|
# @raise [ValidationError] on a missing client_id/redirect_uri, an invalid
|
|
@@ -177,6 +177,17 @@ module Assinafy
|
|
|
177
177
|
end
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
# RFC 6749 §4.1.3/§6 and RFC 7009 §2.1 define token and revocation
|
|
181
|
+
# requests as form posts. The connection's JSON and multipart middleware
|
|
182
|
+
# leave a pre-encoded String body alone.
|
|
183
|
+
def http_post_form(path, form, workspace_auth: true)
|
|
184
|
+
@connection.post(path) do |request|
|
|
185
|
+
prepare_request(request, {}, workspace_auth: workspace_auth)
|
|
186
|
+
request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
187
|
+
request.body = URI.encode_www_form(form)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
180
191
|
def http_put(path, body = nil, params = {}, workspace_auth: true)
|
|
181
192
|
@connection.put(path) do |request|
|
|
182
193
|
prepare_request(request, params, workspace_auth: workspace_auth)
|
|
@@ -204,7 +215,7 @@ module Assinafy
|
|
|
204
215
|
end
|
|
205
216
|
|
|
206
217
|
def call(label)
|
|
207
|
-
|
|
218
|
+
unwrap(request(label) { yield })
|
|
208
219
|
end
|
|
209
220
|
|
|
210
221
|
def call_optional(label)
|
|
@@ -223,7 +234,7 @@ module Assinafy
|
|
|
223
234
|
def call_binary(label)
|
|
224
235
|
response = request(label) { yield }
|
|
225
236
|
body = response.body
|
|
226
|
-
body =
|
|
237
|
+
body = unwrap(response) if body.is_a?(Hash)
|
|
227
238
|
content_type = response.headers&.[]('content-type').to_s.downcase
|
|
228
239
|
|
|
229
240
|
if body.is_a?(String) && !body.empty? && !textual_content_type?(content_type)
|
|
@@ -235,7 +246,7 @@ module Assinafy
|
|
|
235
246
|
|
|
236
247
|
def call_array(label)
|
|
237
248
|
response = request(label) { yield }
|
|
238
|
-
body =
|
|
249
|
+
body = unwrap(response)
|
|
239
250
|
return body if body.is_a?(Array)
|
|
240
251
|
|
|
241
252
|
raise unexpected_response(label, 'an Array data payload', response, body)
|
|
@@ -243,7 +254,7 @@ module Assinafy
|
|
|
243
254
|
|
|
244
255
|
def call_list(label)
|
|
245
256
|
response = request(label) { yield }
|
|
246
|
-
body =
|
|
257
|
+
body = unwrap(response)
|
|
247
258
|
# @type var result: Assinafy::list_result
|
|
248
259
|
result = { data: extract_list_data(body, label, response) }
|
|
249
260
|
meta = parse_pagination_meta(response.headers)
|
|
@@ -268,10 +279,30 @@ module Assinafy
|
|
|
268
279
|
raise Assinafy::Error.new("#{label}: #{e.message}", { cause: e.class.name })
|
|
269
280
|
end
|
|
270
281
|
|
|
282
|
+
# On a `403` the `WWW-Authenticate` challenge names the scope an OAuth
|
|
283
|
+
# token is missing, which tells "reconnect with more scope" apart from
|
|
284
|
+
# "wrong workspace or role", so it travels with the error.
|
|
271
285
|
def check_status!(response, _label)
|
|
272
286
|
return if (200..299).cover?(response.status)
|
|
273
287
|
|
|
274
|
-
raise
|
|
288
|
+
raise with_challenge(error_class.from_response(response.status, response.body), response)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# A 2xx envelope can still carry an error status, with the same challenge.
|
|
292
|
+
def unwrap(response)
|
|
293
|
+
Utils.handle_assinafy_response(response.body)
|
|
294
|
+
rescue ApiError => e
|
|
295
|
+
raise with_challenge(e, response)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def with_challenge(error, response)
|
|
299
|
+
challenge = response.headers&.[]('www-authenticate')
|
|
300
|
+
error.context[:www_authenticate] = challenge if challenge
|
|
301
|
+
error
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def error_class
|
|
305
|
+
ApiError
|
|
275
306
|
end
|
|
276
307
|
|
|
277
308
|
def extract_list_data(body, label, response)
|
|
@@ -573,6 +573,7 @@ module Assinafy
|
|
|
573
573
|
# {
|
|
574
574
|
# 'hash' => 'FE32EDDADE7CBDDCBB934E7402047450B0E59C02',
|
|
575
575
|
# 'id' => '63ddb172402799bfc991d10d',
|
|
576
|
+
# 'agreement_code' => '550E8400-E29B-41D4-A716-446655440000', # printed on the certificate
|
|
576
577
|
# 'status' => 'certificated',
|
|
577
578
|
# 'page_count' => '1',
|
|
578
579
|
# 'signer_count' => '1',
|
|
@@ -582,8 +583,8 @@ module Assinafy
|
|
|
582
583
|
# 'is_valid' => true,
|
|
583
584
|
# 'message' => ''
|
|
584
585
|
# }
|
|
585
|
-
# # Not verified: { 'hash' => 'INVALIDHASHEXAMPLE', 'id' => nil, '
|
|
586
|
-
# # 'is_valid' => false, 'message' => 'Document not signed or not found.', ... }
|
|
586
|
+
# # Not verified: { 'hash' => 'INVALIDHASHEXAMPLE', 'id' => nil, 'agreement_code' => nil,
|
|
587
|
+
# # 'status' => nil, 'is_valid' => false, 'message' => 'Document not signed or not found.', ... }
|
|
587
588
|
def verify(hash)
|
|
588
589
|
h = require_id(hash, 'Signature hash')
|
|
589
590
|
|
|
@@ -18,15 +18,19 @@ module Assinafy
|
|
|
18
18
|
# `{status, data, message}`. These methods return the body as-is.
|
|
19
19
|
# 2. **No workspace credentials.** `/oauth/token` and `/oauth/revoke` are
|
|
20
20
|
# unauthenticated routes that identify the client through `client_id` in
|
|
21
|
-
# the body, so the SDK strips `X-Api-Key`/`Authorization` from them
|
|
21
|
+
# the body, so the SDK strips `X-Api-Key`/`Authorization` from them and
|
|
22
|
+
# posts that body form-encoded, as RFC 6749 and RFC 7009 define it.
|
|
22
23
|
# 3. **OAuth-shaped errors.** A failure raises {Assinafy::OAuthError}, which
|
|
23
24
|
# carries `error` and `error_description` separately.
|
|
24
25
|
#
|
|
25
26
|
# An OAuth access token never reaches billing, account lifecycle, credential
|
|
26
27
|
# management or admin surfaces, whatever scopes it holds. A request missing
|
|
27
28
|
# a scope answers `403` with `WWW-Authenticate: Bearer
|
|
28
|
-
# error="insufficient_scope"` naming it; the SDK puts
|
|
29
|
-
# {Assinafy::Error#context} under `:www_authenticate`.
|
|
29
|
+
# error="insufficient_scope"` naming it; on every resource, the SDK puts
|
|
30
|
+
# that header in {Assinafy::Error#context} under `:www_authenticate`. Treat
|
|
31
|
+
# it as a prompt to reconnect with that scope added, not as a retry; a `403`
|
|
32
|
+
# without it means another workspace, the user's role, or an area OAuth
|
|
33
|
+
# tokens never reach.
|
|
30
34
|
#
|
|
31
35
|
# @example End-to-end: exchange a callback code, then act as the user
|
|
32
36
|
# tokens = Assinafy::Client.new.oauth.exchange_code(
|
|
@@ -35,11 +39,13 @@ module Assinafy
|
|
|
35
39
|
# code_verifier: session.delete(:assinafy_code_verifier),
|
|
36
40
|
# redirect_uri: 'https://app.example.com/oauth/callback'
|
|
37
41
|
# )
|
|
42
|
+
# access_token = tokens.fetch('access_token')
|
|
38
43
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
44
|
+
# # The token works only in the workspace the user picked; store its id
|
|
45
|
+
# # next to the tokens.
|
|
46
|
+
# workspace_id = Assinafy::Client.new(token: access_token).accounts.list[:data].first.fetch('id')
|
|
47
|
+
#
|
|
48
|
+
# user_client = Assinafy::Client.new(token: access_token, account_id: workspace_id)
|
|
43
49
|
# user_client.documents.list
|
|
44
50
|
#
|
|
45
51
|
# @see Assinafy::OAuth
|
|
@@ -59,11 +65,14 @@ module Assinafy
|
|
|
59
65
|
# Exchange an authorization code for tokens (RFC 6749 §4.1.3 + PKCE).
|
|
60
66
|
#
|
|
61
67
|
# Call this on your OAuth callback, after checking that the returned
|
|
62
|
-
# `state`
|
|
63
|
-
#
|
|
64
|
-
# {Assinafy::OAuth
|
|
65
|
-
#
|
|
66
|
-
# `
|
|
68
|
+
# `state` and `iss` match the values you stored for this authorization
|
|
69
|
+
# attempt (`iss` is the issuer of the authorization server it used,
|
|
70
|
+
# {Assinafy::OAuth::AUTHORIZATION_SERVER} in production). The code is
|
|
71
|
+
# single-use and expires 60 seconds after approval: exchange it at once,
|
|
72
|
+
# and never retry. `code_verifier` must be the exact value whose
|
|
73
|
+
# challenge was sent to {Assinafy::OAuth.authorization_url} — the SDK
|
|
74
|
+
# validates its grammar locally, because the server reports a malformed
|
|
75
|
+
# verifier as `invalid_grant`, indistinguishable from an expired code.
|
|
67
76
|
#
|
|
68
77
|
# @param code [String] the `code` query parameter from the callback
|
|
69
78
|
# @param client_id [String] the registered client identifier
|
|
@@ -89,14 +98,13 @@ module Assinafy
|
|
|
89
98
|
# redirect_uri: 'https://app.example.com/oauth/callback'
|
|
90
99
|
# )
|
|
91
100
|
#
|
|
92
|
-
# # Request body sent by the SDK
|
|
93
|
-
# #
|
|
94
|
-
# #
|
|
95
|
-
# #
|
|
96
|
-
# #
|
|
97
|
-
# #
|
|
98
|
-
# #
|
|
99
|
-
# # }
|
|
101
|
+
# # Request body sent by the SDK, application/x-www-form-urlencoded
|
|
102
|
+
# # (no X-Api-Key/Authorization header):
|
|
103
|
+
# # grant_type=authorization_code
|
|
104
|
+
# # client_id=client-id
|
|
105
|
+
# # code=authorization-code-from-callback
|
|
106
|
+
# # code_verifier=<43-128 unreserved characters>
|
|
107
|
+
# # redirect_uri=https://app.example.com/oauth/callback
|
|
100
108
|
# #
|
|
101
109
|
# # Response (flat, NOT enveloped):
|
|
102
110
|
# # {
|
|
@@ -126,22 +134,45 @@ module Assinafy
|
|
|
126
134
|
# consented. Without one, send the user through the authorization flow
|
|
127
135
|
# again once the access token expires.
|
|
128
136
|
#
|
|
129
|
-
#
|
|
137
|
+
# Every refresh returns a new refresh token, valid for another 30 days,
|
|
138
|
+
# and retires the one sent, so a connection ends only after 30 days
|
|
139
|
+
# without a refresh. Reusing a retired refresh token ends the whole
|
|
140
|
+
# connection. Store the new refresh and access tokens before using
|
|
141
|
+
# either, and run one refresh at a time per connection.
|
|
142
|
+
#
|
|
143
|
+
# The SDK sends this request once and never retries it. After an
|
|
144
|
+
# ambiguous failure (a timeout, a reset connection, a 5xx) the server may
|
|
145
|
+
# have rotated the token without the response arriving: re-read the
|
|
146
|
+
# stored token, and if it is still the one sent, never send it again —
|
|
147
|
+
# ask the user to connect again. Proceed only if another worker has since
|
|
148
|
+
# stored a different one. Only a failure that provably happened before
|
|
149
|
+
# sending (DNS, a refused connection, a failed TLS handshake) is safe to
|
|
150
|
+
# retry.
|
|
151
|
+
#
|
|
152
|
+
# @param refresh_token [String] the most recently issued refresh token
|
|
130
153
|
# @param client_id [String] the registered client identifier
|
|
131
154
|
# @param client_secret [String, nil] confidential clients only
|
|
132
|
-
# @param resource [String, nil] RFC 8707 resource indicator
|
|
133
|
-
#
|
|
155
|
+
# @param resource [String, nil] RFC 8707 resource indicator; may repeat
|
|
156
|
+
# the value sent when authorizing, never change it (`invalid_target`)
|
|
157
|
+
# @return [Hash{String=>Object}] the flat token response, always with a
|
|
158
|
+
# new `refresh_token`
|
|
134
159
|
# @raise [Assinafy::OAuthError] `invalid_grant` when the refresh token is
|
|
135
|
-
# unknown,
|
|
160
|
+
# unknown, already used, expired, or revoked, or the user approved the
|
|
161
|
+
# app again with different permissions: the connection is over, so ask
|
|
162
|
+
# the user to connect again instead of retrying
|
|
163
|
+
# @raise [Assinafy::Error] when a success carries no new refresh token
|
|
164
|
+
# (missing, blank, or the one sent): the one sent may already be
|
|
165
|
+
# retired, so handle it like `invalid_grant`
|
|
136
166
|
#
|
|
137
167
|
# @see POST /oauth/token
|
|
138
168
|
#
|
|
139
169
|
# @example Request and response
|
|
140
170
|
# client.oauth.refresh(refresh_token: stored_refresh_token, client_id: 'client-id')
|
|
141
171
|
#
|
|
142
|
-
# # Request body sent by the SDK:
|
|
143
|
-
# #
|
|
144
|
-
# #
|
|
172
|
+
# # Request body sent by the SDK, application/x-www-form-urlencoded:
|
|
173
|
+
# # grant_type=refresh_token
|
|
174
|
+
# # client_id=client-id
|
|
175
|
+
# # refresh_token=refresh-token-placeholder
|
|
145
176
|
# #
|
|
146
177
|
# # Response (flat, NOT enveloped):
|
|
147
178
|
# # {
|
|
@@ -164,13 +195,17 @@ module Assinafy
|
|
|
164
195
|
# Call the token endpoint directly.
|
|
165
196
|
#
|
|
166
197
|
# {#exchange_code} and {#refresh} cover both supported grants; reach for
|
|
167
|
-
# this only to send a parameter they do not model.
|
|
198
|
+
# this only to send a parameter they do not model. The `refresh_token`
|
|
199
|
+
# grant gets the same rotation check, and the same retry rules, as
|
|
200
|
+
# {#refresh}.
|
|
168
201
|
#
|
|
169
202
|
# @param grant_type [String] `"authorization_code"` or `"refresh_token"`
|
|
170
203
|
# @param client_id [String] the registered client identifier
|
|
171
204
|
# @param params [Hash] additional body parameters; nil values are dropped
|
|
172
205
|
# @return [Hash{String=>Object}] the flat token response
|
|
173
206
|
# @raise [Assinafy::OAuthError] on any non-2xx response
|
|
207
|
+
# @raise [Assinafy::Error] when a `refresh_token` grant succeeds without a
|
|
208
|
+
# new refresh token
|
|
174
209
|
# @raise [Assinafy::ValidationError] on an unsupported `grant_type`
|
|
175
210
|
#
|
|
176
211
|
# @see POST /oauth/token
|
|
@@ -180,21 +215,26 @@ module Assinafy
|
|
|
180
215
|
raise ValidationError.new("Grant type must be one of: #{GRANT_TYPES.join(', ')}", { grant_type: grant_type })
|
|
181
216
|
end
|
|
182
217
|
|
|
183
|
-
body
|
|
218
|
+
body = body_params(params.merge(grant_type: grant, client_id: require_string(client_id, 'Client ID')))
|
|
219
|
+
label = 'Failed to exchange OAuth token'
|
|
184
220
|
|
|
185
221
|
@logger.info("Requesting OAuth token (#{grant})")
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
222
|
+
response = request(label) { http_post_form('oauth/token', body, workspace_auth: false) }
|
|
223
|
+
tokens = unwrap(response)
|
|
224
|
+
return tokens if grant != 'refresh_token' || rotated?(tokens, body['refresh_token'])
|
|
225
|
+
|
|
226
|
+
raise unexpected_response(label, 'a new refresh token', response, tokens)
|
|
189
227
|
end
|
|
190
228
|
|
|
191
229
|
# Revoke an access or refresh token (RFC 7009).
|
|
192
230
|
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
231
|
+
# Call it when a user disconnects, with the refresh token in storage at
|
|
232
|
+
# that moment, then delete the stored tokens. Revoking a refresh token
|
|
233
|
+
# also invalidates the access tokens issued from it. Every token outcome
|
|
234
|
+
# answers `200` — including a token that is unknown, already revoked or
|
|
235
|
+
# rotated, or malformed — so the endpoint cannot be used to probe whether
|
|
236
|
+
# a token exists, and revoking a stale copy can look successful while the
|
|
237
|
+
# connection stays active. Only failed client authentication raises.
|
|
198
238
|
#
|
|
199
239
|
# @param token [String] the access or refresh token to revoke
|
|
200
240
|
# @param client_id [String] the registered client identifier
|
|
@@ -212,9 +252,11 @@ module Assinafy
|
|
|
212
252
|
# token_type_hint: 'refresh_token'
|
|
213
253
|
# )
|
|
214
254
|
#
|
|
215
|
-
# # Request body sent by the SDK
|
|
216
|
-
# #
|
|
217
|
-
# #
|
|
255
|
+
# # Request body sent by the SDK, application/x-www-form-urlencoded
|
|
256
|
+
# # (no X-Api-Key/Authorization header):
|
|
257
|
+
# # token=refresh-token-placeholder
|
|
258
|
+
# # client_id=client-id
|
|
259
|
+
# # token_type_hint=refresh_token
|
|
218
260
|
# #
|
|
219
261
|
# # Response: HTTP 200, empty body
|
|
220
262
|
# # => nil
|
|
@@ -235,7 +277,7 @@ module Assinafy
|
|
|
235
277
|
|
|
236
278
|
@logger.info('Revoking OAuth token')
|
|
237
279
|
call_void('Failed to revoke OAuth token') do
|
|
238
|
-
|
|
280
|
+
http_post_form('oauth/revoke', body, workspace_auth: false)
|
|
239
281
|
end
|
|
240
282
|
end
|
|
241
283
|
|
|
@@ -355,15 +397,17 @@ module Assinafy
|
|
|
355
397
|
|
|
356
398
|
# OAuth routes report failures as RFC 6749 error objects rather than this
|
|
357
399
|
# API's envelope, so they raise {OAuthError} (an {ApiError}, so existing
|
|
358
|
-
# `rescue Assinafy::ApiError` handlers keep working).
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
return if (200..299).cover?(response.status)
|
|
400
|
+
# `rescue Assinafy::ApiError` handlers keep working).
|
|
401
|
+
def error_class
|
|
402
|
+
OAuthError
|
|
403
|
+
end
|
|
363
404
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
405
|
+
# A refresh retires the refresh token it sends. A success without a
|
|
406
|
+
# different one leaves nothing to refresh with next time, and sending the
|
|
407
|
+
# retired one again ends the whole connection.
|
|
408
|
+
def rotated?(tokens, sent)
|
|
409
|
+
replacement = tokens['refresh_token'] if tokens.is_a?(Hash)
|
|
410
|
+
replacement.is_a?(String) && !replacement.strip.empty? && replacement != sent
|
|
367
411
|
end
|
|
368
412
|
end
|
|
369
413
|
end
|
data/lib/assinafy/version.rb
CHANGED