Class: XeroKiwi::OAuth
- Inherits:
-
Object
- Object
- XeroKiwi::OAuth
- Defined in:
- lib/xero_kiwi/oauth.rb,
lib/xero_kiwi/oauth/pkce.rb,
lib/xero_kiwi/oauth/id_token.rb
Overview
Implements the Xero OAuth2 Authorization Code flow. Stateless: each call is a pure function over its arguments, so the same OAuth instance can serve both halves of the redirect (authorise → callback) even when those halves run in different processes.
The caller owns session storage for ‘state` (CSRF) and the PKCE `code_verifier` — XeroKiwi gives you helpers to generate them but doesn’t touch your session/cookies/Redis.
oauth = XeroKiwi::OAuth.new(
client_id: ENV["XERO_CLIENT_ID"],
client_secret: ENV["XERO_CLIENT_SECRET"],
redirect_uri: "https://app.example.com/xero/callback"
)
# Step 1: kick off authorisation
state = XeroKiwi::OAuth.generate_state
pkce = XeroKiwi::OAuth.generate_pkce
session[:xero_state] = state
session[:xero_verifier] = pkce.verifier
redirect_to oauth.(
scopes: %w[openid profile email accounting.transactions offline_access],
state: state,
pkce: pkce
)
# Step 2: callback
XeroKiwi::OAuth.verify_state!(
received: params[:state],
expected: session.delete(:xero_state)
)
token = oauth.exchange_code(
code: params[:code],
code_verifier: session.delete(:xero_verifier)
)
See: developer.xero.com/documentation/guides/oauth2/auth-flow
Defined Under Namespace
Classes: CodeExchangeError, IDToken, IDTokenError, PKCE, StateMismatchError
Constant Summary collapse
- JWKS_CACHE_TTL =
3600
Instance Attribute Summary collapse
-
#client_id ⇒ Object
readonly
Returns the value of attribute client_id.
-
#client_secret ⇒ Object
readonly
Returns the value of attribute client_secret.
-
#redirect_uri ⇒ Object
readonly
Returns the value of attribute redirect_uri.
Class Method Summary collapse
-
.generate_pkce ⇒ Object
Generates a fresh PKCE verifier+challenge pair.
-
.generate_state(byte_length: 32) ⇒ Object
Generates a cryptographically random ‘state` value for CSRF protection.
-
.verify_state!(received:, expected:) ⇒ Object
Constant-time comparison of the state Xero echoed back vs the value we stashed.
Instance Method Summary collapse
-
#authorization_url(scopes:, state:, pkce: nil, nonce: nil) ⇒ Object
Builds the authorisation URL the caller redirects the user to.
-
#exchange_code(code:, code_verifier: nil) ⇒ Object
Exchanges an authorisation code for a XeroKiwi::Token.
-
#initialize(client_id:, client_secret:, redirect_uri: nil, adapter: nil) ⇒ OAuth
constructor
‘redirect_uri:` is required for the auth-code flow itself (`authorization_url` and `exchange_code`) but not for `revoke_token` or `verify_id_token`.
-
#revoke_token(refresh_token:) ⇒ Object
Revokes a refresh token at Xero’s revocation endpoint (RFC 7009).
-
#verify_id_token(id_token, nonce: nil) ⇒ Object
Verifies an OIDC id_token JWT using this OAuth instance’s client_id as the audience.
Constructor Details
#initialize(client_id:, client_secret:, redirect_uri: nil, adapter: nil) ⇒ OAuth
‘redirect_uri:` is required for the auth-code flow itself (`authorization_url` and `exchange_code`) but not for `revoke_token` or `verify_id_token`. It’s optional at construction time so callers who only need the latter operations don’t have to invent a fake URL.
98 99 100 101 102 103 104 105 106 |
# File 'lib/xero_kiwi/oauth.rb', line 98 def initialize(client_id:, client_secret:, redirect_uri: nil, adapter: nil) @client_id = client_id @client_secret = client_secret @redirect_uri = redirect_uri @adapter = adapter @jwks_mutex = Mutex.new @jwks_cache = nil @jwks_fetched_at = nil end |
Instance Attribute Details
#client_id ⇒ Object (readonly)
Returns the value of attribute client_id.
63 64 65 |
# File 'lib/xero_kiwi/oauth.rb', line 63 def client_id @client_id end |
#client_secret ⇒ Object (readonly)
Returns the value of attribute client_secret.
63 64 65 |
# File 'lib/xero_kiwi/oauth.rb', line 63 def client_secret @client_secret end |
#redirect_uri ⇒ Object (readonly)
Returns the value of attribute redirect_uri.
63 64 65 |
# File 'lib/xero_kiwi/oauth.rb', line 63 def redirect_uri @redirect_uri end |
Class Method Details
.generate_pkce ⇒ Object
Generates a fresh PKCE verifier+challenge pair.
73 74 75 |
# File 'lib/xero_kiwi/oauth.rb', line 73 def self.generate_pkce PKCE.generate end |
.generate_state(byte_length: 32) ⇒ Object
Generates a cryptographically random ‘state` value for CSRF protection. Caller stashes this somewhere request-scoped (session, signed cookie) before redirecting and verifies it on callback.
68 69 70 |
# File 'lib/xero_kiwi/oauth.rb', line 68 def self.generate_state(byte_length: 32) SecureRandom.urlsafe_base64(byte_length) end |
.verify_state!(received:, expected:) ⇒ Object
Constant-time comparison of the state Xero echoed back vs the value we stashed. Raises StateMismatchError on any mismatch — including nil values, length mismatches, or content mismatches. The length check up front is required because OpenSSL.fixed_length_secure_compare raises ArgumentError on unequal-length input.
82 83 84 |
# File 'lib/xero_kiwi/oauth.rb', line 82 def self.verify_state!(received:, expected:) raise StateMismatchError, "OAuth state parameter mismatch" if state_mismatch?(received, expected) end |
Instance Method Details
#authorization_url(scopes:, state:, pkce: nil, nonce: nil) ⇒ Object
Builds the authorisation URL the caller redirects the user to. The returned URL is opaque — the caller’s job is just to redirect to it.
‘state` is required (CSRF). `pkce` is optional but recommended; pass a XeroKiwi::OAuth::PKCE instance and you’ll need to supply the matching ‘code_verifier:` at exchange time.
114 115 116 117 118 119 120 |
# File 'lib/xero_kiwi/oauth.rb', line 114 def (scopes:, state:, pkce: nil, nonce: nil) raise ArgumentError, "redirect_uri was not configured at construction time" if redirect_uri.nil? raise ArgumentError, "scopes cannot be empty" if Array(scopes).empty? raise ArgumentError, "state is required" if state.nil? || state.empty? "#{Identity::AUTHORIZE_URL}?#{URI.encode_www_form((scopes, state, pkce, nonce))}" end |
#exchange_code(code:, code_verifier: nil) ⇒ Object
Exchanges an authorisation code for a XeroKiwi::Token. Pass the same ‘code_verifier` you used to build the authorisation URL — or omit it if you didn’t use PKCE.
125 126 127 128 129 130 131 132 133 134 |
# File 'lib/xero_kiwi/oauth.rb', line 125 def exchange_code(code:, code_verifier: nil) raise ArgumentError, "redirect_uri was not configured at construction time" if redirect_uri.nil? raise ArgumentError, "code is required" if code.nil? || code.empty? requested_at = Time.now response = post_token_exchange(code, code_verifier) Token.from_oauth_response(response.body, requested_at: requested_at) rescue AuthenticationError, ClientError => e raise CodeExchangeError.new(e.status, e.body) end |
#revoke_token(refresh_token:) ⇒ Object
Revokes a refresh token at Xero’s revocation endpoint (RFC 7009). Revoking the refresh token also invalidates every access token that was issued from it, so this is the right call to clean up after “disconnect Xero” / logout flows.
Pass the refresh token, not the access token. Per RFC 7009 the endpoint accepts either, but Xero only invalidates the chain when you revoke the refresh token — passing an access token leaves the refresh token alive, which is almost never what you want.
145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/xero_kiwi/oauth.rb', line 145 def revoke_token(refresh_token:) raise ArgumentError, "refresh_token is required" if refresh_token.nil? || refresh_token.empty? http.post(Identity::REVOKE_PATH) do |req| req.headers["Authorization"] = Identity.basic_auth_header(client_id, client_secret) req.headers["Content-Type"] = "application/x-www-form-urlencoded" req.body = URI.encode_www_form( token: refresh_token, token_type_hint: "refresh_token" ) end true end |
#verify_id_token(id_token, nonce: nil) ⇒ Object
Verifies an OIDC id_token JWT using this OAuth instance’s client_id as the audience. Uses the instance-level JWKS cache so repeated verifications don’t refetch Xero’s signing keys for every callback.
162 163 164 165 166 167 168 169 |
# File 'lib/xero_kiwi/oauth.rb', line 162 def verify_id_token(id_token, nonce: nil) IDToken.verify( id_token, client_id: client_id, nonce: nonce, jwks: -> { cached_jwks } ) end |