xero-kiwi 0.1.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 +7 -0
- data/.env.example +2 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +89 -0
- data/Rakefile +89 -0
- data/docs/accounting/address.md +54 -0
- data/docs/accounting/branding-theme.md +92 -0
- data/docs/accounting/contact-group.md +91 -0
- data/docs/accounting/contact.md +166 -0
- data/docs/accounting/credit-note.md +97 -0
- data/docs/accounting/external-link.md +33 -0
- data/docs/accounting/invoice.md +134 -0
- data/docs/accounting/organisation.md +119 -0
- data/docs/accounting/overpayment.md +94 -0
- data/docs/accounting/payment-terms.md +58 -0
- data/docs/accounting/payment.md +99 -0
- data/docs/accounting/phone.md +45 -0
- data/docs/accounting/prepayment.md +111 -0
- data/docs/accounting/user.md +109 -0
- data/docs/client.md +174 -0
- data/docs/connections.md +166 -0
- data/docs/errors.md +271 -0
- data/docs/getting-started.md +138 -0
- data/docs/oauth.md +508 -0
- data/docs/retries-and-rate-limits.md +224 -0
- data/docs/tokens.md +339 -0
- data/lib/xero_kiwi/accounting/address.rb +58 -0
- data/lib/xero_kiwi/accounting/allocation.rb +66 -0
- data/lib/xero_kiwi/accounting/branding_theme.rb +76 -0
- data/lib/xero_kiwi/accounting/contact.rb +153 -0
- data/lib/xero_kiwi/accounting/contact_group.rb +57 -0
- data/lib/xero_kiwi/accounting/contact_person.rb +45 -0
- data/lib/xero_kiwi/accounting/credit_note.rb +115 -0
- data/lib/xero_kiwi/accounting/external_link.rb +38 -0
- data/lib/xero_kiwi/accounting/invoice.rb +142 -0
- data/lib/xero_kiwi/accounting/line_item.rb +64 -0
- data/lib/xero_kiwi/accounting/organisation.rb +138 -0
- data/lib/xero_kiwi/accounting/overpayment.rb +107 -0
- data/lib/xero_kiwi/accounting/payment.rb +105 -0
- data/lib/xero_kiwi/accounting/payment_terms.rb +77 -0
- data/lib/xero_kiwi/accounting/phone.rb +46 -0
- data/lib/xero_kiwi/accounting/prepayment.rb +109 -0
- data/lib/xero_kiwi/accounting/tracking_category.rb +42 -0
- data/lib/xero_kiwi/accounting/user.rb +80 -0
- data/lib/xero_kiwi/client.rb +576 -0
- data/lib/xero_kiwi/connection.rb +78 -0
- data/lib/xero_kiwi/errors.rb +34 -0
- data/lib/xero_kiwi/identity.rb +40 -0
- data/lib/xero_kiwi/oauth/id_token.rb +102 -0
- data/lib/xero_kiwi/oauth/pkce.rb +51 -0
- data/lib/xero_kiwi/oauth.rb +232 -0
- data/lib/xero_kiwi/token.rb +99 -0
- data/lib/xero_kiwi/token_refresher.rb +53 -0
- data/lib/xero_kiwi/version.rb +5 -0
- data/lib/xero_kiwi.rb +33 -0
- data/llms-full.txt +3351 -0
- data/llms.txt +56 -0
- data/sig/xero_kiwi.rbs +4 -0
- metadata +164 -0
data/docs/oauth.md
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
# OAuth
|
|
2
|
+
|
|
3
|
+
`XeroKiwi::OAuth` implements the Xero OAuth2 authorization-code flow. It's
|
|
4
|
+
**stateless** — every method is a pure function over its arguments — so the
|
|
5
|
+
same `OAuth` instance can serve both halves of the redirect (authorise →
|
|
6
|
+
callback) even when those halves run in different processes.
|
|
7
|
+
|
|
8
|
+
This doc walks through the full flow, the helper methods, PKCE, ID token
|
|
9
|
+
verification, and token revocation. For the long-term token lifecycle
|
|
10
|
+
(refresh, expiry, persistence callbacks), see [Tokens](tokens.md).
|
|
11
|
+
|
|
12
|
+
## The flow at a glance
|
|
13
|
+
|
|
14
|
+
1. **Build an authorise URL** with `oauth.authorization_url(...)` and
|
|
15
|
+
redirect the user there.
|
|
16
|
+
2. The user signs in to Xero, picks tenants, grants permissions, and Xero
|
|
17
|
+
redirects them to your `redirect_uri` with `?code=...&state=...`.
|
|
18
|
+
3. **Verify the `state`** with `XeroKiwi::OAuth.verify_state!` to defeat CSRF.
|
|
19
|
+
4. **Exchange the code** for a token with `oauth.exchange_code(...)`.
|
|
20
|
+
5. **(Optional) Verify the ID token** with `oauth.verify_id_token(...)` if
|
|
21
|
+
you need to know who the user is.
|
|
22
|
+
6. **Use the access token** to call `client.connections` and the rest of
|
|
23
|
+
the API.
|
|
24
|
+
|
|
25
|
+
## Constructing an OAuth instance
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
oauth = XeroKiwi::OAuth.new(
|
|
29
|
+
client_id: ENV.fetch("XERO_CLIENT_ID"),
|
|
30
|
+
client_secret: ENV.fetch("XERO_CLIENT_SECRET"),
|
|
31
|
+
redirect_uri: "https://app.example.com/xero/callback"
|
|
32
|
+
)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Constructor options
|
|
36
|
+
|
|
37
|
+
| Option | Type | Required | Default | Purpose |
|
|
38
|
+
|--------|------|----------|---------|---------|
|
|
39
|
+
| `client_id:` | `String` | Yes | — | Your Xero app's client ID |
|
|
40
|
+
| `client_secret:` | `String` | Yes | — | Your Xero app's client secret |
|
|
41
|
+
| `redirect_uri:` | `String` | Conditional | `nil` | Required for `authorization_url` and `exchange_code`. **Optional** if you only use this instance for `revoke_token` or `verify_id_token`. |
|
|
42
|
+
| `adapter:` | Faraday adapter | No | `Faraday.default_adapter` | The HTTP adapter for token-endpoint and JWKS calls |
|
|
43
|
+
|
|
44
|
+
### Why `redirect_uri:` is optional
|
|
45
|
+
|
|
46
|
+
The OAuth instance is reused for several different operations. Some need
|
|
47
|
+
`redirect_uri`, some don't:
|
|
48
|
+
|
|
49
|
+
- **`authorization_url`**: Yes, always.
|
|
50
|
+
- **`exchange_code`**: Yes (Xero requires it in the form body for the exchange).
|
|
51
|
+
- **`revoke_token`**: No.
|
|
52
|
+
- **`verify_id_token`**: No.
|
|
53
|
+
|
|
54
|
+
If you're building a "revoke-only" instance for a logout flow, or a
|
|
55
|
+
"verify-only" instance for an external ID token, you don't need to invent a
|
|
56
|
+
fake redirect URL. Pass `redirect_uri: nil` (or just omit it). If you then
|
|
57
|
+
*do* call `authorization_url` or `exchange_code`, you'll get a clear
|
|
58
|
+
`ArgumentError` at call time rather than a confusing 400 from Xero.
|
|
59
|
+
|
|
60
|
+
## Step 1: building the authorisation URL
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
state = XeroKiwi::OAuth.generate_state
|
|
64
|
+
pkce = XeroKiwi::OAuth.generate_pkce
|
|
65
|
+
|
|
66
|
+
session[:xero_state] = state
|
|
67
|
+
session[:xero_verifier] = pkce.verifier
|
|
68
|
+
|
|
69
|
+
redirect_to oauth.authorization_url(
|
|
70
|
+
scopes: %w[openid profile email accounting.transactions offline_access],
|
|
71
|
+
state: state,
|
|
72
|
+
pkce: pkce
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `authorization_url` options
|
|
77
|
+
|
|
78
|
+
| Option | Type | Required | Purpose |
|
|
79
|
+
|--------|------|----------|---------|
|
|
80
|
+
| `scopes:` | `Array<String>` or `String` | Yes | The Xero scopes to request. Passed in space-separated to Xero. |
|
|
81
|
+
| `state:` | `String` | Yes | A random per-request value used for CSRF protection. Generate with `XeroKiwi::OAuth.generate_state` and stash in your session. |
|
|
82
|
+
| `pkce:` | `XeroKiwi::OAuth::PKCE` | No | A PKCE pair to bind the auth code to this request. See below. |
|
|
83
|
+
| `nonce:` | `String` | No | An optional OIDC nonce to embed in the ID token. Useful if you'll verify it on the callback. |
|
|
84
|
+
|
|
85
|
+
The returned URL is opaque — your job is just to redirect to it. Don't
|
|
86
|
+
parse it, don't reformat it.
|
|
87
|
+
|
|
88
|
+
### Common scope strings
|
|
89
|
+
|
|
90
|
+
Xero scopes are documented in their [scopes reference](https://developer.xero.com/documentation/guides/oauth2/scopes/).
|
|
91
|
+
Some commonly useful ones:
|
|
92
|
+
|
|
93
|
+
| Scope | What it grants |
|
|
94
|
+
|-------|----------------|
|
|
95
|
+
| `openid` | Required for OIDC (gets you an `id_token`) |
|
|
96
|
+
| `profile` | The user's display name |
|
|
97
|
+
| `email` | The user's email address |
|
|
98
|
+
| `offline_access` | **Required if you want a refresh token.** Without this, you only get an access token that dies in 30 minutes. |
|
|
99
|
+
| `accounting.transactions` | Read/write invoices, bills, payments, etc. |
|
|
100
|
+
| `accounting.contacts` | Read/write contacts |
|
|
101
|
+
| `accounting.settings` | Read accounting settings |
|
|
102
|
+
|
|
103
|
+
If you forget `offline_access`, you'll get a token but no `refresh_token`,
|
|
104
|
+
and `client.can_refresh?` will be false forever.
|
|
105
|
+
|
|
106
|
+
## State (CSRF protection)
|
|
107
|
+
|
|
108
|
+
The `state` parameter is the OAuth2 standard for defeating cross-site
|
|
109
|
+
request forgery. Without it, an attacker could trick a logged-in user into
|
|
110
|
+
linking their own Xero account to the attacker's app account.
|
|
111
|
+
|
|
112
|
+
### Generating and verifying
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# Before the redirect:
|
|
116
|
+
state = XeroKiwi::OAuth.generate_state
|
|
117
|
+
session[:xero_state] = state
|
|
118
|
+
|
|
119
|
+
# In your callback:
|
|
120
|
+
XeroKiwi::OAuth.verify_state!(
|
|
121
|
+
received: params[:state],
|
|
122
|
+
expected: session.delete(:xero_state)
|
|
123
|
+
)
|
|
124
|
+
# Raises XeroKiwi::OAuth::StateMismatchError on any mismatch.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### How the verification works
|
|
128
|
+
|
|
129
|
+
`verify_state!` does **constant-time** comparison via
|
|
130
|
+
`OpenSSL.fixed_length_secure_compare`, with a length check up front (the
|
|
131
|
+
OpenSSL function raises on unequal lengths, and the length itself isn't
|
|
132
|
+
secret).
|
|
133
|
+
|
|
134
|
+
It raises `XeroKiwi::OAuth::StateMismatchError` if:
|
|
135
|
+
|
|
136
|
+
- Either value is `nil`.
|
|
137
|
+
- The values have different byte lengths.
|
|
138
|
+
- The values have the same length but different content.
|
|
139
|
+
|
|
140
|
+
The error message is deliberately generic ("OAuth state parameter
|
|
141
|
+
mismatch") — don't reveal *what* the values were in user-facing output.
|
|
142
|
+
|
|
143
|
+
### State storage tips
|
|
144
|
+
|
|
145
|
+
- **Use the session** in a typical web app. Sessions are server-side or
|
|
146
|
+
signed, so a user can't tamper with the stashed state.
|
|
147
|
+
- **Use `session.delete(...)`** in the callback to remove the stashed value
|
|
148
|
+
in one go, preventing replay attacks where the same callback URL is hit
|
|
149
|
+
twice.
|
|
150
|
+
- **Don't store state in a cookie** unless that cookie is signed. Otherwise
|
|
151
|
+
the user can rewrite it.
|
|
152
|
+
|
|
153
|
+
## PKCE (Proof Key for Code Exchange)
|
|
154
|
+
|
|
155
|
+
PKCE binds the auth code to the original authorisation request: the client
|
|
156
|
+
generates a random verifier, sends a hash of it on the authorise call, then
|
|
157
|
+
proves possession of the original verifier when exchanging the code. An
|
|
158
|
+
attacker who intercepts the auth code can't redeem it without the verifier.
|
|
159
|
+
|
|
160
|
+
It's **required** for public clients (mobile apps, SPAs, anything where the
|
|
161
|
+
client secret isn't actually secret). For server-side confidential clients
|
|
162
|
+
it's **recommended** as defence in depth — there's no real downside.
|
|
163
|
+
|
|
164
|
+
### Generating a PKCE pair
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
pkce = XeroKiwi::OAuth.generate_pkce
|
|
168
|
+
# or:
|
|
169
|
+
pkce = XeroKiwi::OAuth::PKCE.generate
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A `XeroKiwi::OAuth::PKCE` exposes:
|
|
173
|
+
|
|
174
|
+
| Attribute | What it is |
|
|
175
|
+
|-----------|------------|
|
|
176
|
+
| `pkce.verifier` | A 43-character URL-safe random string. **Stash this in your session** — you'll need it on the callback. |
|
|
177
|
+
| `pkce.challenge` | The base64url-encoded SHA256 of the verifier (with no padding). This is what gets sent to Xero on the authorise call. |
|
|
178
|
+
| `pkce.to_h` | The form params Xero expects: `{ code_verifier:, code_challenge:, code_challenge_method: "S256" }`. Useful for testing or for building requests by hand. |
|
|
179
|
+
|
|
180
|
+
### Using PKCE in the flow
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
# Authorise:
|
|
184
|
+
pkce = XeroKiwi::OAuth.generate_pkce
|
|
185
|
+
session[:xero_verifier] = pkce.verifier
|
|
186
|
+
redirect_to oauth.authorization_url(
|
|
187
|
+
scopes: %w[...],
|
|
188
|
+
state: state,
|
|
189
|
+
pkce: pkce # passes code_challenge + code_challenge_method to Xero
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Callback:
|
|
193
|
+
token = oauth.exchange_code(
|
|
194
|
+
code: params[:code],
|
|
195
|
+
code_verifier: session.delete(:xero_verifier) # prove we're the same client
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
If you pass a `pkce:` to `authorization_url` but **forget** the
|
|
200
|
+
`code_verifier:` on `exchange_code`, Xero will reject the exchange with
|
|
201
|
+
`invalid_grant` and XeroKiwi will raise `XeroKiwi::OAuth::CodeExchangeError`.
|
|
202
|
+
|
|
203
|
+
## Step 2: handling the callback
|
|
204
|
+
|
|
205
|
+
Xero redirects the user to your `redirect_uri` with one of two query string
|
|
206
|
+
shapes:
|
|
207
|
+
|
|
208
|
+
**On success:**
|
|
209
|
+
```
|
|
210
|
+
https://app.example.com/xero/callback?code=ABC123&state=stashed_state
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**On failure** (user denied, scope rejected, etc):
|
|
214
|
+
```
|
|
215
|
+
https://app.example.com/xero/callback?error=access_denied&error_description=...
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Always check for `error=` first:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
def callback
|
|
222
|
+
if params[:error]
|
|
223
|
+
redirect_to root_path, alert: params[:error_description]
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
XeroKiwi::OAuth.verify_state!(
|
|
228
|
+
received: params[:state],
|
|
229
|
+
expected: session.delete(:xero_state)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# ... exchange the code
|
|
233
|
+
end
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Step 3: exchanging the code
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
token = oauth.exchange_code(
|
|
240
|
+
code: params[:code],
|
|
241
|
+
code_verifier: session.delete(:xero_verifier) # only if you used PKCE
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### `exchange_code` options
|
|
246
|
+
|
|
247
|
+
| Option | Type | Required | Purpose |
|
|
248
|
+
|--------|------|----------|---------|
|
|
249
|
+
| `code:` | `String` | Yes | The auth code from the callback query string |
|
|
250
|
+
| `code_verifier:` | `String` | Conditional | Required if you sent a PKCE challenge in the authorise step |
|
|
251
|
+
|
|
252
|
+
### Return value
|
|
253
|
+
|
|
254
|
+
A `XeroKiwi::Token` containing the access token, refresh token, expires_at,
|
|
255
|
+
id_token, and scope. See [Tokens](tokens.md) for the full reference.
|
|
256
|
+
|
|
257
|
+
### Error behaviour
|
|
258
|
+
|
|
259
|
+
| Cause | Exception |
|
|
260
|
+
|-------|-----------|
|
|
261
|
+
| Code already used / expired (Xero codes are short-lived) | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
262
|
+
| Wrong `redirect_uri` (must match the one used at authorise time) | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
263
|
+
| PKCE verifier mismatch | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
264
|
+
| Wrong client credentials | `XeroKiwi::OAuth::CodeExchangeError` |
|
|
265
|
+
| Network error | bubbles up as a `Faraday::ConnectionFailed` |
|
|
266
|
+
|
|
267
|
+
`XeroKiwi::OAuth::CodeExchangeError` inherits from `XeroKiwi::AuthenticationError`,
|
|
268
|
+
so you can catch the broader class if you don't care about the distinction.
|
|
269
|
+
|
|
270
|
+
## ID token verification
|
|
271
|
+
|
|
272
|
+
If you requested the `openid` scope, Xero returns an `id_token` (a JWT) in
|
|
273
|
+
the token response. The ID token contains claims about who the user is —
|
|
274
|
+
their `sub`, `email`, `given_name`, etc. **You should verify it before
|
|
275
|
+
trusting any of those claims**, otherwise an attacker who steals an access
|
|
276
|
+
token could feed you a forged ID token to impersonate someone.
|
|
277
|
+
|
|
278
|
+
### Verifying via the OAuth instance (recommended)
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
verified = oauth.verify_id_token(token.id_token)
|
|
282
|
+
|
|
283
|
+
verified.subject # the OIDC `sub` — Xero's user identifier
|
|
284
|
+
verified.email # if `email` scope was granted
|
|
285
|
+
verified.given_name # if `profile` scope was granted
|
|
286
|
+
verified.family_name
|
|
287
|
+
verified.expires_at # Time
|
|
288
|
+
verified.issued_at # Time
|
|
289
|
+
verified.claims # full claims hash
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
This route uses the OAuth instance's **JWKS cache**: the first verification
|
|
293
|
+
fetches Xero's signing keys from
|
|
294
|
+
`https://identity.xero.com/.well-known/openid-configuration/jwks` and the
|
|
295
|
+
result is cached in memory for an hour. Subsequent verifications on the
|
|
296
|
+
same instance reuse the cached keys, so verifying 100 tokens in a session
|
|
297
|
+
costs you exactly one HTTPS round-trip.
|
|
298
|
+
|
|
299
|
+
### Verifying standalone
|
|
300
|
+
|
|
301
|
+
If you don't have an `OAuth` instance handy:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
verified = XeroKiwi::OAuth::IDToken.verify(
|
|
305
|
+
id_token,
|
|
306
|
+
client_id: "your_client_id"
|
|
307
|
+
)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The standalone class method **fetches JWKS fresh on every call** via
|
|
311
|
+
`Net::HTTP`. Fine for one-off use, wasteful in a hot loop. If you find
|
|
312
|
+
yourself calling it repeatedly, switch to the OAuth instance method.
|
|
313
|
+
|
|
314
|
+
You can also inject your own JWKS provider:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
XeroKiwi::OAuth::IDToken.verify(
|
|
318
|
+
id_token,
|
|
319
|
+
client_id: "...",
|
|
320
|
+
jwks: -> { my_cached_jwks_hash }
|
|
321
|
+
)
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The `jwks:` proc must return an array of JWK hashes (the contents of the
|
|
325
|
+
`"keys"` array in a standard JWKS document).
|
|
326
|
+
|
|
327
|
+
### What gets verified
|
|
328
|
+
|
|
329
|
+
| Check | How |
|
|
330
|
+
|-------|-----|
|
|
331
|
+
| Signature | RS256, against the public key whose `kid` matches the JWT header. Other algorithms are rejected. |
|
|
332
|
+
| Issuer (`iss`) | Must equal `https://identity.xero.com`. |
|
|
333
|
+
| Audience (`aud`) | Must equal the `client_id` you passed in. |
|
|
334
|
+
| Expiry (`exp`) | Must be in the future (with no clock skew tolerance). |
|
|
335
|
+
| Nonce (`nonce`) | Only if you pass `nonce:` to `verify`. Constant-time compare. |
|
|
336
|
+
|
|
337
|
+
Anything that fails raises `XeroKiwi::OAuth::IDTokenError` with a descriptive
|
|
338
|
+
message ("ID token verification failed: ...").
|
|
339
|
+
|
|
340
|
+
### Nonce verification
|
|
341
|
+
|
|
342
|
+
If you sent a nonce in the authorise call, you should verify it on the way
|
|
343
|
+
back:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
# Authorise:
|
|
347
|
+
nonce = SecureRandom.urlsafe_base64(16)
|
|
348
|
+
session[:xero_nonce] = nonce
|
|
349
|
+
redirect_to oauth.authorization_url(scopes: ..., state: ..., nonce: nonce)
|
|
350
|
+
|
|
351
|
+
# Callback:
|
|
352
|
+
verified = oauth.verify_id_token(
|
|
353
|
+
token.id_token,
|
|
354
|
+
nonce: session.delete(:xero_nonce)
|
|
355
|
+
)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The nonce check fails if the token doesn't contain a `nonce` claim, if the
|
|
359
|
+
claim doesn't match what you sent, or if the comparison would have raised.
|
|
360
|
+
|
|
361
|
+
## Token revocation
|
|
362
|
+
|
|
363
|
+
Use this for "disconnect Xero from my app" / logout flows.
|
|
364
|
+
|
|
365
|
+
### Via the Client (most common)
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
client.revoke_token!
|
|
369
|
+
credential.destroy!
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
See [Tokens — revoking tokens](tokens.md#revoking-tokens) for the full
|
|
373
|
+
story.
|
|
374
|
+
|
|
375
|
+
### Via OAuth directly
|
|
376
|
+
|
|
377
|
+
If you have a refresh token in hand:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
oauth = XeroKiwi::OAuth.new(
|
|
381
|
+
client_id: ENV.fetch("XERO_CLIENT_ID"),
|
|
382
|
+
client_secret: ENV.fetch("XERO_CLIENT_SECRET")
|
|
383
|
+
# no redirect_uri needed
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
oauth.revoke_token(refresh_token: "1//...")
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Returns `true` on success, raises `XeroKiwi::AuthenticationError`/`XeroKiwi::ClientError`
|
|
390
|
+
on failure.
|
|
391
|
+
|
|
392
|
+
### Why we always revoke the refresh token
|
|
393
|
+
|
|
394
|
+
Per RFC 7009 you can pass either an access token or a refresh token to the
|
|
395
|
+
revoke endpoint. **But** revoking the access token only kills that one
|
|
396
|
+
access token — the refresh token is still alive and can mint a new one
|
|
397
|
+
immediately. Revoking the refresh token invalidates the entire chain.
|
|
398
|
+
|
|
399
|
+
`XeroKiwi::OAuth#revoke_token` only accepts a refresh token (the keyword arg
|
|
400
|
+
is `refresh_token:`) precisely to prevent the foot-gun of "I called revoke
|
|
401
|
+
but the user is still logged in."
|
|
402
|
+
|
|
403
|
+
## Full Rails-style example
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
class XeroOAuthController < ApplicationController
|
|
407
|
+
def authorize
|
|
408
|
+
state = XeroKiwi::OAuth.generate_state
|
|
409
|
+
pkce = XeroKiwi::OAuth.generate_pkce
|
|
410
|
+
|
|
411
|
+
session[:xero_state] = state
|
|
412
|
+
session[:xero_verifier] = pkce.verifier
|
|
413
|
+
|
|
414
|
+
redirect_to oauth.authorization_url(
|
|
415
|
+
scopes: %w[openid profile email accounting.transactions offline_access],
|
|
416
|
+
state: state,
|
|
417
|
+
pkce: pkce
|
|
418
|
+
)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def callback
|
|
422
|
+
if params[:error]
|
|
423
|
+
redirect_to root_path, alert: params[:error_description]
|
|
424
|
+
return
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
XeroKiwi::OAuth.verify_state!(
|
|
428
|
+
received: params[:state],
|
|
429
|
+
expected: session.delete(:xero_state)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
token = oauth.exchange_code(
|
|
433
|
+
code: params[:code],
|
|
434
|
+
code_verifier: session.delete(:xero_verifier)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Confirm who the user is before storing anything
|
|
438
|
+
identity = oauth.verify_id_token(token.id_token)
|
|
439
|
+
|
|
440
|
+
# Discover tenants
|
|
441
|
+
api_client = XeroKiwi::Client.new(access_token: token.access_token)
|
|
442
|
+
tenants = api_client.connections
|
|
443
|
+
|
|
444
|
+
# Persist the credential
|
|
445
|
+
XeroCredential.create!(
|
|
446
|
+
user_email: identity.email,
|
|
447
|
+
access_token: token.access_token,
|
|
448
|
+
refresh_token: token.refresh_token,
|
|
449
|
+
expires_at: token.expires_at,
|
|
450
|
+
tenants: tenants.map { |t| { id: t.tenant_id, name: t.tenant_name } }
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
redirect_to dashboard_path
|
|
454
|
+
rescue XeroKiwi::OAuth::StateMismatchError
|
|
455
|
+
redirect_to root_path, alert: "Authentication failed (CSRF check)"
|
|
456
|
+
rescue XeroKiwi::OAuth::CodeExchangeError
|
|
457
|
+
redirect_to root_path, alert: "Could not complete Xero authorisation"
|
|
458
|
+
rescue XeroKiwi::OAuth::IDTokenError
|
|
459
|
+
redirect_to root_path, alert: "Could not verify Xero identity token"
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def disconnect
|
|
463
|
+
credential = current_user.xero_credential
|
|
464
|
+
|
|
465
|
+
client = XeroKiwi::Client.new(
|
|
466
|
+
access_token: credential.access_token,
|
|
467
|
+
refresh_token: credential.refresh_token,
|
|
468
|
+
expires_at: credential.expires_at,
|
|
469
|
+
client_id: ENV.fetch("XERO_CLIENT_ID"),
|
|
470
|
+
client_secret: ENV.fetch("XERO_CLIENT_SECRET")
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
client.revoke_token!
|
|
474
|
+
credential.destroy!
|
|
475
|
+
redirect_to root_path, notice: "Disconnected from Xero"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
private
|
|
479
|
+
|
|
480
|
+
def oauth
|
|
481
|
+
@oauth ||= XeroKiwi::OAuth.new(
|
|
482
|
+
client_id: Rails.application.credentials.xero[:client_id],
|
|
483
|
+
client_secret: Rails.application.credentials.xero[:client_secret],
|
|
484
|
+
redirect_uri: xero_oauth_callback_url
|
|
485
|
+
)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
## Things OAuth deliberately does NOT do
|
|
491
|
+
|
|
492
|
+
- **No session storage.** XeroKiwi gives you `generate_state` and
|
|
493
|
+
`generate_pkce` as helpers but never touches your session/cookies/Redis.
|
|
494
|
+
Where you stash the values is your problem — and that's a feature,
|
|
495
|
+
because every framework is different.
|
|
496
|
+
- **No automatic state tracking on the OAuth instance.** Some gems offer a
|
|
497
|
+
stateful "session" object that holds the state and verifier in memory
|
|
498
|
+
between authorise and callback. That only works if both halves of the
|
|
499
|
+
flow run in the same process, which isn't true for any web app where the
|
|
500
|
+
redirect lands on a different request. Stateless functions compose
|
|
501
|
+
better.
|
|
502
|
+
- **No `at_hash` verification on ID tokens.** OIDC defines an optional
|
|
503
|
+
`at_hash` claim that lets you verify the access token hasn't been
|
|
504
|
+
tampered with in transit. It's rarely used and adds complexity. Skip
|
|
505
|
+
until requested.
|
|
506
|
+
- **No nonce generation helper.** `SecureRandom.urlsafe_base64(16)` at
|
|
507
|
+
the call site is already simple — could add `OAuth.generate_nonce` for
|
|
508
|
+
symmetry with `generate_state`, but it'd be one line of code.
|