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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +2 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +89 -0
  6. data/Rakefile +89 -0
  7. data/docs/accounting/address.md +54 -0
  8. data/docs/accounting/branding-theme.md +92 -0
  9. data/docs/accounting/contact-group.md +91 -0
  10. data/docs/accounting/contact.md +166 -0
  11. data/docs/accounting/credit-note.md +97 -0
  12. data/docs/accounting/external-link.md +33 -0
  13. data/docs/accounting/invoice.md +134 -0
  14. data/docs/accounting/organisation.md +119 -0
  15. data/docs/accounting/overpayment.md +94 -0
  16. data/docs/accounting/payment-terms.md +58 -0
  17. data/docs/accounting/payment.md +99 -0
  18. data/docs/accounting/phone.md +45 -0
  19. data/docs/accounting/prepayment.md +111 -0
  20. data/docs/accounting/user.md +109 -0
  21. data/docs/client.md +174 -0
  22. data/docs/connections.md +166 -0
  23. data/docs/errors.md +271 -0
  24. data/docs/getting-started.md +138 -0
  25. data/docs/oauth.md +508 -0
  26. data/docs/retries-and-rate-limits.md +224 -0
  27. data/docs/tokens.md +339 -0
  28. data/lib/xero_kiwi/accounting/address.rb +58 -0
  29. data/lib/xero_kiwi/accounting/allocation.rb +66 -0
  30. data/lib/xero_kiwi/accounting/branding_theme.rb +76 -0
  31. data/lib/xero_kiwi/accounting/contact.rb +153 -0
  32. data/lib/xero_kiwi/accounting/contact_group.rb +57 -0
  33. data/lib/xero_kiwi/accounting/contact_person.rb +45 -0
  34. data/lib/xero_kiwi/accounting/credit_note.rb +115 -0
  35. data/lib/xero_kiwi/accounting/external_link.rb +38 -0
  36. data/lib/xero_kiwi/accounting/invoice.rb +142 -0
  37. data/lib/xero_kiwi/accounting/line_item.rb +64 -0
  38. data/lib/xero_kiwi/accounting/organisation.rb +138 -0
  39. data/lib/xero_kiwi/accounting/overpayment.rb +107 -0
  40. data/lib/xero_kiwi/accounting/payment.rb +105 -0
  41. data/lib/xero_kiwi/accounting/payment_terms.rb +77 -0
  42. data/lib/xero_kiwi/accounting/phone.rb +46 -0
  43. data/lib/xero_kiwi/accounting/prepayment.rb +109 -0
  44. data/lib/xero_kiwi/accounting/tracking_category.rb +42 -0
  45. data/lib/xero_kiwi/accounting/user.rb +80 -0
  46. data/lib/xero_kiwi/client.rb +576 -0
  47. data/lib/xero_kiwi/connection.rb +78 -0
  48. data/lib/xero_kiwi/errors.rb +34 -0
  49. data/lib/xero_kiwi/identity.rb +40 -0
  50. data/lib/xero_kiwi/oauth/id_token.rb +102 -0
  51. data/lib/xero_kiwi/oauth/pkce.rb +51 -0
  52. data/lib/xero_kiwi/oauth.rb +232 -0
  53. data/lib/xero_kiwi/token.rb +99 -0
  54. data/lib/xero_kiwi/token_refresher.rb +53 -0
  55. data/lib/xero_kiwi/version.rb +5 -0
  56. data/lib/xero_kiwi.rb +33 -0
  57. data/llms-full.txt +3351 -0
  58. data/llms.txt +56 -0
  59. data/sig/xero_kiwi.rbs +4 -0
  60. 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.