Class: XeroKiwi::OAuth

Inherits:
Object
  • Object
show all
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.authorization_url(
  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

Class Method Summary collapse

Instance Method Summary collapse

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_idObject (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_secretObject (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_uriObject (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_pkceObject

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.

Raises:



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.

Raises:

  • (ArgumentError)


114
115
116
117
118
119
120
# File 'lib/xero_kiwi/oauth.rb', line 114

def authorization_url(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(authorize_params(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.

Raises:

  • (ArgumentError)


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