diff --git a/Gemfile.lock b/Gemfile.lock index b53a0ab5..8192da95 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,16 +1,26 @@ PATH remote: . specs: - xero-ruby (2.9.1) + xero-ruby (2.10.0) faraday (~> 1.0, >= 1.0.1) json (~> 2.1, >= 2.1.0) + json-jwt (~> 1.5, >= 1.5.2) GEM remote: https://rubygems.org/ specs: + activesupport (6.0.3.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + zeitwerk (~> 2.2, >= 2.2.2) + aes_key_wrap (1.1.0) ast (2.4.1) + bindata (2.4.9) byebug (11.1.3) coderay (1.1.3) + concurrent-ruby (1.1.8) diff-lcs (1.4.4) faraday (1.4.1) faraday-excon (~> 1.1) @@ -21,9 +31,16 @@ GEM faraday-excon (1.1.0) faraday-net_http (1.0.1) faraday-net_http_persistent (1.1.0) + i18n (1.8.7) + concurrent-ruby (~> 1.0) jaro_winkler (1.5.4) json (2.5.1) + json-jwt (1.13.0) + activesupport (>= 4.2) + aes_key_wrap + bindata method_source (1.0.0) + minitest (5.14.3) multipart-post (2.1.1) parallel (1.20.1) parser (2.7.2.0) @@ -60,7 +77,11 @@ GEM unicode-display_width (>= 1.4.0, < 1.6) ruby-progressbar (1.10.1) ruby2_keywords (0.0.4) + thread_safe (0.3.6) + tzinfo (1.2.9) + thread_safe (~> 0.1) unicode-display_width (1.5.0) + zeitwerk (2.4.2) PLATFORMS ruby diff --git a/README.md b/README.md index 5cf2096c..65719af7 100644 --- a/README.md +++ b/README.md @@ -5,45 +5,39 @@ Xero Ruby SDK for OAuth 2.0 generated from [Xero API OpenAPI Spec](https://githu # Documentation Xero Ruby SDK supports Xero's OAuth2.0 authentication and the following Xero API sets. - -## SDK Documentation +### API Client Documentation * [API client methods](https://xeroapi.github.io/xero-ruby/accounting/index.html) ---- -## API Model Docs -* [Accounting Models](/docs/accounting) -* [Asset Api Docs](/docs/assets/) -* [Project Api Docs](docs/projects/) -* [Files Api Docs](docs/files/) -* [Payroll Docs (AU)](docs/payroll_au/) -* [Payroll Docs (NZ)](docs/payroll_nz/) -* [Payroll Docs (UK)](docs/payroll_uk/) - +> This describes to ~200+ accounting API endpoints and their expected params. There are also method reference docs for the Assets, Files, Projects, and Payroll endpoints sets, though we are still working on accurately generating usable parameter examples for all! (feedback welcome) +### Model Docs +* [Models](/docs/) +> Directory of markdown files, describing the object models for the Accounting, Asset, Projects, Files, Payroll (AU, UK, NZ) Xero API sets. ## Sample Apps -We have two apps showing SDK usage. -* https://github.com/XeroAPI/xero-ruby-oauth2-starter (**Sinatra** - session based / getting started) -* https://github.com/XeroAPI/xero-ruby-oauth2-app (**Rails** - token management / full examples) +We have two sample apps showing SDK usage: +* https://github.com/XeroAPI/xero-ruby-oauth2-starter (**Sinatra** - bare minimum to hello world and simple session based storage) +* https://github.com/XeroAPI/xero-ruby-oauth2-app (**Rails** - token management with robust usage examples) ![sample-app](https://i.imgur.com/OOEn55G.png) ---- +## Xero Pre-Requisites +* Create a [free Xero user account](https://www.xero.com/us/signup/api/) +* Login to your Xero developer [/myapps](https://developer.xero.com/myapps) dashboard & create an API application +* Copy the credentials from your API app and store/access them using a secure ENV variable strategy +* Resaearch and include the [neccesary scopes](https://developer.xero.com/documentation/oauth2/scopes) for your app's functionality as a space-seperated list, ex. "`SCOPES="openid profile email accounting.transactions accounting.settings"`" + + + ## Installation -To install this gem to your current gemset. +To install this gem to your project: ``` gem install 'xero-ruby' ``` -Or add to your gemfile and run `bundle install`. +Or more commonly in Ruby on Rails usage add to your gemfile and run `bundle install`: ``` gem 'xero-ruby' ``` -## Getting Started -* Create a [free Xero user account](https://www.xero.com/us/signup/api/) -* Login to your Xero developer [/myapps](https://developer.xero.com/myapps) dashboard & create an API application and note your API app's credentials. - -### Creating a client -* Get the credential values from an API application at https://developer.xero.com/myapps/. -* Include [neccesary scopes](https://developer.xero.com/documentation/oauth2/scopes) as a space-seperated list - * example => "`openid profile email accounting.transactions accounting.settings`" +--- +## Usage ``` require 'xero-ruby' ``` @@ -58,56 +52,77 @@ creds = { xero_client ||= XeroRuby::ApiClient.new(credentials: creds) ``` -If you want additional logging or timeout, you can add/override any configuration option by passing the optional named parameter object `config`. +For additional logging or timeout, add or override any [config](/lib/xero-ruby/configuration.rb) option by passing an optional named parameter `config: {..}`. ```ruby config = { timeout: 30, debugging: true } @xero_client ||= XeroRuby::ApiClient.new(credentials: creds, config: config) ``` -## User Authorization & Callback -All API requests require a valid access token to be set on the client. +## OAuth2.0 Authorization & Callback +All API requests require a valid access token to be set on the xero_client. -To generate a valid `token_set` send a user to the `authorization_url`: +### Step 1 +Send the user to the `authorization_url` after you have configured your xero_client ```ruby @authorization_url = xero_client.authorization_url redirect_to @authorization_url ``` -Xero will then redirect back to the URI defined in your `redirect_uri` config. - -*This must match **exactly** with the variable in your /myapps dashboard.* - -In your callback, calling `get_token_set_from_callback` will exchange the temporary code Xero return, with a valid `token_set` that you can use to make API calls. +### Step 2 +On successful authorization, Xero identity will redirect to the URI defined in your `redirect_uri` config which must match **exactly** with the variable in your /myapps dashboard. +``` +=> /oauth/redirect_uri +``` +### Step 3 +In your server defined callback route, exchange the temporary code for a valid `token_set` that will get set on your client. ```ruby -# => http://localhost:3000/oauth/callback - token_set = xero_client.get_token_set_from_callback(params) +``` +At this point you should save the token_set as JSON in a datastore in relation to the authenticating user or entity. -# save token_set JSON in a datastore in relation to the user authentication - -puts params['state'] -=> "this-can-be-a-custom-state-parameter" +The sample [Rails app](https://github.com/XeroAPI/xero-ruby-oauth2-app/blob/master/app/controllers/application_controller.rb#L11) shows a solid pattern you can tweak to fit your needs: +```ruby +# /oauth/redirect_uri -> 'application#callback' +def callback + @token_set = @xero_client.get_token_set_from_callback(params) + + current_user.token_set = @token_set + current_user.token_set['connections'] = @xero_client.connections + current_user.active_tenant_id = latest_connection(current_user.token_set['connections']) + current_user.save! + flash.notice = "Successfully authenticated with Xero!" +end ``` +--- +### What is a Token Set? +A `token_set` is what we call the XeroAPI response that contains data about your API connection: +```json +{ + "id_token": "xxx.yyy.zz", (if you requested `openid profile email` scope) + "access_token": "xxx.yyy.zzz", + "expires_in": 1800, + "token_type": "Bearer", + "refresh_token": "xxxxxx", (if you requested `offline_access` scope) + "scope": "email profile openid accounting.transactions offline_access" +} +``` + +Note that an `access_token` is valid for 30 minutes but a `refresh_token` can be used once in up to a 60 day window. If a refresh_token is used to refresh access you must replace the entire token_set. -## Making API calls once you have a token_set -Once you already have a token_set stored from this initual user interaction, you can setup a new client by passing the whole token_set to `refresh_token_set` or `set_token_set`. +Both the `id_token` & `access_token` are JWT's, and can be decoded for to see additional metadata described in the Token Helpers section: +## Making API calls with a valid token_set +After the initial user interaction you can simply setup a xero_client by passing the whole token_set to the client. ```ruby xero_client.set_token_set(user.token_set) xero_client.refresh_token_set(user.token_set) -# this will set the access_token on the client, and return a refreshed `token_set` you need to save. ``` -A `token_set` contains data about your API connection most importantly : -* `access_token` -* `refresh_token` -* `expiry` - -**An `access_token` is valid 30 minutes and a `refresh_token` is valid for 60 days** - -Example Token set: -> You can decode the `id_token` & `access_token` for additional metadata by using a [decoding library](https://github.com/jwt/ruby-jwt): -```json +This sets the access_token on the client, and returns a refreshed `token_set` you should save in your database for the next time you need to connect to Xero's API. +## Token Helpers +```ruby +xero_client.token_set +=> { "id_token": "xxx.yyy.zz", "access_token": "xxx.yyy.zzz", @@ -116,17 +131,63 @@ Example Token set: "refresh_token": "xxxxxx", "scope": "email profile openid accounting.transactions offline_access" } -``` -## Token & SDK Helpers -Refresh/connection helpers +xero_client.access_token +=> "xxx.yyy.zzz" + +xero_client.decoded_access_token +=> { + "exp": 1619715843, + "xero_userid": "xero-user-uuid", + "scope": [ + "email", + "profile", + "openid", + "accounting.transactions", + "offline_access" + ] + } + + +xero_client.id_token +=> "aaa.bbb.ccc" + +xero_client.decoded_id_token +=> { + "iss": "https://identity.xero.com", + "email": "luca.pacioli@accounting-services.com", + "given_name": "Luca", + "family_name": "Pacioli" + } + +xero_client.set_token_set(token_set) +=> true + +xero_client.get_token_set_from_callback(callback_url_params) +=> new_xero_token_set + +xero_client.refresh_token_set(token_set) +=> new_xero_token_set + +# These are automatically populated with `set_token_set` +# But if you need to set just an access or id token on the client +xero_client.set_access_token(access_token) +xero_client.set_id_token(id_token) + +# Automatically run on initial OAuth flow - can be called its own if desired +# Read about why we have included this in the default library: https://auth0.com/docs/tokens/access-tokens/validate-access-tokens +xero_client.validate_tokens(token_set) +xero_client.decode_jwt(tkn) +``` +# Connection Helpers ```ruby -@token_set = xero_client.refresh_token_set(user.token_set) +xero_client.authorization_url +=> # https://login.xero.com/identity/connect/authorize?response_type=code&client_id=&redirect_uri=&scope=&state= -# Xero's tokens can potentially facilitate (n) org connections in a single token. -# It is important to store the `tenantId` of the Organisation your user wants to read/write data. +# To completely Revoke a user's access token and all their connections +xero_client.revoke_token(token_set) -# The `updatedDateUtc` will show you the most recently authorized Tenant (AKA Organisation) +# In case there are > 1 tenants connected the `updatedDateUtc` will show you the most recently authorized tenant (aka organisation) - it is important to store the `tenantId` of the Org your user specified in their API authorization connections = xero_client.connections [{ "id" => "xxx-yyy-zzz", @@ -137,38 +198,19 @@ connections = xero_client.connections "updatedDateUtc" => "2020-04-15T22:37:10.4943410" }] -# To completely Revoke a user's access token and all their connections -# pass in the users token set to the #revoke_token api_client method - -xero_client.revoke_token(user.token_set) - -# disconnect an org from a user's connections. Pass the connection ['id'] not ['tenantId']. -# Useful if you want to enforce only a single org connection per token. +# To disconnect a single org from a user's active connections pass the connection ['id'] (not ['tenantId']) +# If you want to enforce only a single org connection per token do this prior to sending user through Xero authorize flow a 2nd time. remaining_connections = xero_client.disconnect(connections[0]['id']) -# set a refreshed token_set -token_set = xero_client.set_token_set(user.token_set) +xero_client.token_expired? +=> true || false -# access token_set once it is set on the client -token_set = xero_client.token_set +# This will check against the following logic +token_expiry = Time.at(decoded_access_token['exp']) +token_expiry < Time.now ``` -Example token expiry helper -```ruby -require 'jwt' - -def token_expired? - token_expiry = Time.at(decoded_access_token['exp']) - token_expiry < Time.now -end - -def decoded_access_token - JWT.decode(token_set['access_token'], nil, false)[0] -end -``` - -## API Usage - +# API Usage ### Accounting API > https://xeroapi.github.io/xero-ruby/accounting/index.html ```ruby @@ -177,7 +219,7 @@ require 'xero-ruby' xero_client.refresh_token_set(user.token_set) tenant_id = user.active_tenant_id -# example of how to store the `tenantId` of the specific tenant (aka organisation) +# Example 'active tenant' logic storage of the tenant the user specified, xero_client.connections[0] is not a safe assumption in case they authorized multiple orgs. # Get Accounts accounts = xero_client.accounting_api.get_accounts(tenant_id).accounts @@ -331,19 +373,13 @@ opts = { } xero_client.accounting_api.get_bank_transfers(tenant_id, opts).bank_transfers ``` -### NOTE + 1) Not all `opts` parameter combinations are available for all endpoints, and there are likely some undiscovered edge cases. If you encounter a filter / sort / where clause that seems buggy open an issue and we will dig. 2) Some opts string values may need PascalCasing to match casing defined in our [core API docs](https://developer.xero.com/documentation/api/api-overview). * `opts = { order: 'UpdatedDateUtc DESC'}` 3) If you have use cases outside of these examples let us know. - -## Sample App -The best resource to understanding how to best leverage this SDK is the sample applications showing all the features of the gem. -> https://github.com/XeroAPI/xero-ruby-oauth2-starter (Sinatra - simple getting started) -> https://github.com/XeroAPI/xero-ruby-oauth2-app (Rails - full featured examples) - ## Developing locally To develop this gem locally against your project you can use the following development pattern: diff --git a/docs/accounting/AccountingApi.md b/docs/accounting/AccountingApi.md index 454b91a7..fbcfa74b 100644 --- a/docs/accounting/AccountingApi.md +++ b/docs/accounting/AccountingApi.md @@ -6623,7 +6623,9 @@ opts = { page: 1, # Integer | e.g. page=1 - Up to 100 contacts will be returned in a single API call. - include_archived: true # Boolean | e.g. includeArchived=true - Contacts with a status of ARCHIVED will be included in the response + include_archived: true, # Boolean | e.g. includeArchived=true - Contacts with a status of ARCHIVED will be included in the response + + summary_only: false # Boolean | Use summaryOnly=true in GET Contacts endpoint to retrieve a smaller version of the response object. This returns only lightweight fields, excluding computation-heavy fields from the response, making the API calls quick and efficient. } begin @@ -6647,6 +6649,7 @@ Name | Type | Description | Notes **i_ds** | [**Array<String>**](String.md)| Filter by a comma separated list of ContactIDs. Allows you to retrieve a specific set of contacts in a single call. | [optional] **page** | **Integer**| e.g. page=1 - Up to 100 contacts will be returned in a single API call. | [optional] **include_archived** | **Boolean**| e.g. includeArchived=true - Contacts with a status of ARCHIVED will be included in the response | [optional] + **summary_only** | **Boolean**| Use summaryOnly=true in GET Contacts endpoint to retrieve a smaller version of the response object. This returns only lightweight fields, excluding computation-heavy fields from the response, making the API calls quick and efficient. | [optional] [default to false] ### Return type diff --git a/docs/accounting/index.html b/docs/accounting/index.html index a0c7533e..5398fda8 100644 --- a/docs/accounting/index.html +++ b/docs/accounting/index.html @@ -3662,7 +3662,6 @@ "type" : "number", "description" : "The calculated tax amount based on the TaxType and LineAmount", "format" : "double", - "readOnly" : true, "example" : 0.0, "x-is-money" : true }, @@ -20715,9 +20714,10 @@

Usage and SDK Samples

ids = ["00000000-0000-0000-0000-000000000000"] page = 1 include_archived = true +summary_only = true begin - response = xero_client.accounting_api.get_contacts(xero_tenant_id, if_modified_since, where, order, ids, page, include_archived) + response = xero_client.accounting_api.get_contacts(xero_tenant_id, if_modified_since, where, order, ids, page, include_archived, summary_only) return response rescue XeroRuby::ApiError => e puts "Exception when calling get_contacts: #{e}" @@ -20895,6 +20895,26 @@

Parameters

+ + + summaryOnly + + + +
+
+
+ + Boolean + + +
+Use summaryOnly=true in GET Contacts endpoint to retrieve a smaller version of the response object. This returns only lightweight fields, excluding computation-heavy fields from the response, making the API calls quick and efficient. +
+
+
+
+ diff --git a/docs/projects/index.html b/docs/projects/index.html index 884b7877..81635fea 100644 --- a/docs/projects/index.html +++ b/docs/projects/index.html @@ -1341,7 +1341,7 @@ "status" : { "type" : "string", "description" : "Status of the time entry. By default a time entry is created with status of `ACTIVE`. A `LOCKED` state indicates that the time entry is currently changing state (for example being invoiced). Updates are not allowed when in this state. It will have a status of INVOICED once it is invoiced.", - "enum" : [ "ACTIVE", "LOCKED" ] + "enum" : [ "ACTIVE", "LOCKED", "INVOICED" ] } }, "description" : "", diff --git a/lib/xero-ruby/api/accounting_api.rb b/lib/xero-ruby/api/accounting_api.rb index 96ac23d3..10f1e394 100644 --- a/lib/xero-ruby/api/accounting_api.rb +++ b/lib/xero-ruby/api/accounting_api.rb @@ -7728,6 +7728,7 @@ def get_contact_history_with_http_info(xero_tenant_id, contact_id, options = {}) # @option opts [Array] :i_ds Filter by a comma separated list of ContactIDs. Allows you to retrieve a specific set of contacts in a single call. # @option opts [Integer] :page e.g. page=1 - Up to 100 contacts will be returned in a single API call. # @option opts [Boolean] :include_archived e.g. includeArchived=true - Contacts with a status of ARCHIVED will be included in the response + # @option opts [Boolean] :summary_only Use summaryOnly=true in GET Contacts endpoint to retrieve a smaller version of the response object. This returns only lightweight fields, excluding computation-heavy fields from the response, making the API calls quick and efficient. (default to false) # @return [Contacts] def get_contacts(xero_tenant_id, opts = {}) data, _status_code, _headers = get_contacts_with_http_info(xero_tenant_id, opts) @@ -7743,6 +7744,7 @@ def get_contacts(xero_tenant_id, opts = {}) # @option opts [Array] :i_ds Filter by a comma separated list of ContactIDs. Allows you to retrieve a specific set of contacts in a single call. # @option opts [Integer] :page e.g. page=1 - Up to 100 contacts will be returned in a single API call. # @option opts [Boolean] :include_archived e.g. includeArchived=true - Contacts with a status of ARCHIVED will be included in the response + # @option opts [Boolean] :summary_only Use summaryOnly=true in GET Contacts endpoint to retrieve a smaller version of the response object. This returns only lightweight fields, excluding computation-heavy fields from the response, making the API calls quick and efficient. # @return [Array<(Contacts, Integer, Hash)>] Contacts data, response status code and response headers def get_contacts_with_http_info(xero_tenant_id, options = {}) opts = options.dup @@ -7766,6 +7768,7 @@ def get_contacts_with_http_info(xero_tenant_id, options = {}) query_params[:'IDs'] = @api_client.build_collection_param(opts[:'i_ds'], :csv) if !opts[:'i_ds'].nil? query_params[:'page'] = opts[:'page'] if !opts[:'page'].nil? query_params[:'includeArchived'] = opts[:'include_archived'] if !opts[:'include_archived'].nil? + query_params[:'summaryOnly'] = opts[:'summary_only'] if !opts[:'summary_only'].nil? # XeroAPI's `IDs` convention openapi-generator does not snake_case properly.. manual over-riding `i_ds` malformations: query_params[:'IDs'] = @api_client.build_collection_param(opts[:'ids'], :csv) if !opts[:'ids'].nil? diff --git a/lib/xero-ruby/api_client.rb b/lib/xero-ruby/api_client.rb index 260c432c..31285e97 100644 --- a/lib/xero-ruby/api_client.rb +++ b/lib/xero-ruby/api_client.rb @@ -17,6 +17,7 @@ require 'faraday' require 'base64' require 'cgi' +require 'json/jwt' module XeroRuby class ApiClient @@ -108,11 +109,22 @@ def id_token @config.id_token end + def decoded_access_token + decode_jwt(@config.access_token) + end + + def decoded_id_token + decode_jwt(@config.id_token) + end + def set_token_set(token_set) - # helper to set the token_set on a client once the user - # has a valid token set ( access_token & refresh_token ) + token_set = token_set.with_indifferent_access @config.token_set = token_set - set_access_token(token_set['access_token']) + + set_access_token(token_set[:access_token]) if token_set[:access_token] + set_id_token(token_set[:id_token]) if token_set[:id_token] + + return true end def set_access_token(access_token) @@ -129,20 +141,52 @@ def get_token_set_from_callback(params) code: params['code'], redirect_uri: @redirect_uri } - return token_request(data, '/token') + token_set = token_request(data, '/token') + + validate_tokens(token_set) + validate_state(params) + return token_set + end + + def validate_tokens(token_set) + id_token = token_set[:id_token] + access_token = token_set[:access_token] + if id_token || access_token + decode_jwt(access_token) if access_token + decode_jwt(id_token) if id_token + end + return true + end + + def validate_state(params) + if params[:state] != @state + raise StandardError.new "WARNING: @config.state: #{@state} and OAuth callback state: #{params['state']} do not match!" + end + return true + end + + def decode_jwt(tkn) + jwks_data = JSON.parse(Faraday.get('https://identity.xero.com/.well-known/openid-configuration/jwks').body) + jwk_set = JSON::JWK::Set.new(jwks_data) + JSON::JWT.decode(tkn, jwk_set) + end + + def token_expired? + token_expiry = Time.at(decoded_access_token['exp']) + token_expiry < Time.now end def refresh_token_set(token_set) data = { grant_type: 'refresh_token', - refresh_token: token_set['refresh_token'] + refresh_token: token_set[:refresh_token] } return token_request(data, '/token') end def revoke_token(token_set) data = { - token: token_set['refresh_token'] + token: token_set[:refresh_token] } return token_request(data, '/revocation') end diff --git a/lib/xero-ruby/models/projects/time_entry.rb b/lib/xero-ruby/models/projects/time_entry.rb index f51b5259..88a4b119 100644 --- a/lib/xero-ruby/models/projects/time_entry.rb +++ b/lib/xero-ruby/models/projects/time_entry.rb @@ -44,6 +44,7 @@ class TimeEntry attr_accessor :status ACTIVE = "ACTIVE".freeze LOCKED = "LOCKED".freeze + INVOICED = "INVOICED".freeze class EnumAttributeValidator attr_reader :datatype @@ -159,7 +160,7 @@ def list_invalid_properties # Check to see if the all the properties in the model are valid # @return true if the model is valid def valid? - status_validator = EnumAttributeValidator.new('String', ["ACTIVE", "LOCKED"]) + status_validator = EnumAttributeValidator.new('String', ["ACTIVE", "LOCKED", "INVOICED"]) return false unless status_validator.valid?(@status) true end @@ -167,7 +168,7 @@ def valid? # Custom attribute writer method checking allowed values (enum). # @param [Object] status Object to be assigned def status=(status) - validator = EnumAttributeValidator.new('String', ["ACTIVE", "LOCKED"]) + validator = EnumAttributeValidator.new('String', ["ACTIVE", "LOCKED", "INVOICED"]) unless validator.valid?(status) fail ArgumentError, "invalid value for \"status\", must be one of #{validator.allowable_values}." end diff --git a/lib/xero-ruby/version.rb b/lib/xero-ruby/version.rb index 1a013880..a298eabf 100644 --- a/lib/xero-ruby/version.rb +++ b/lib/xero-ruby/version.rb @@ -7,9 +7,9 @@ Generated by: https://openapi-generator.tech OpenAPI Generator version: 4.3.1 -The version of the XeroOpenAPI document: 2.10.5 +The version of the XeroOpenAPI document: 2.11.0 =end module XeroRuby - VERSION = '2.9.1' + VERSION = '2.10.0' end diff --git a/spec/api_client_spec.rb b/spec/api_client_spec.rb index a38481b2..87089d21 100644 --- a/spec/api_client_spec.rb +++ b/spec/api_client_spec.rb @@ -60,13 +60,26 @@ api_client = XeroRuby::ApiClient.new(credentials: creds) expect(api_client.authorization_url).to eq('https://login.xero.com/identity/connect/authorize?response_type=code&client_id=abc&redirect_uri=https://mydomain.com/callback&scope=openid+profile+email+accounting.transactions+accounting.settings') end + + it "Validates state on callback matches @config.state" do + creds = { + client_id: 'abc', + client_secret: '123', + redirect_uri: 'https://mydomain.com/callback', + scopes: 'openid profile email accounting.transactions accounting.settings', + state: "custom-state" + } + api_client = XeroRuby::ApiClient.new(credentials: creds) + altered_state = {'state': 'not-original-state'} + expect{api_client.validate_state(altered_state)}.to raise_error(StandardError, 'WARNING: @config.state: custom-state and OAuth callback state: do not match!') + end end end end describe 'api_client helper functions' do let(:api_client) { XeroRuby::ApiClient.new } - let(:token_set) { {access_token: 'eyx.jibberjabber', refresh_token: 'REFRESHMENTS'} } + let(:token_set) { {'access_token': 'eyx.authorization.data', 'id_token': 'eyx.authentication.data', 'refresh_token': 'REFRESHMENTS'} } let(:connections) { [{ "id" => "xxx-yyy-zzz", @@ -84,12 +97,17 @@ it "#set_token_set" do api_client.set_token_set(token_set) - expect(api_client.token_set).to eq(token_set) + expect(api_client.token_set).to eq(token_set.with_indifferent_access) end it "#set_access_token" do - api_client.set_access_token(token_set[:access_token]) - expect(api_client.access_token).to eq(token_set[:access_token]) + api_client.set_access_token(token_set['access_token']) + expect(api_client.access_token).to eq(token_set['access_token']) + end + + it "#set_id_token" do + api_client.set_id_token(token_set['id_token']) + expect(api_client.id_token).to eq(token_set['id_token']) end it "#refresh_token_set" do @@ -372,6 +390,63 @@ end end + describe 'token helper methods' do + let(:api_client) { XeroRuby::ApiClient.new } + let(:id_token){'eyJhbGciOiJSUzI1NiIsImtpZCI6IjFDQUY4RTY2NzcyRDZEQzAyOEQ2NzI2RkQwMjYxNTgxNTcwRUZDMTkiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJISy1PWm5jdGJjQW8xbkp2MENZVmdWY09fQmsifQ.eyJuYmYiOjE2MTk3MTQwNDMsImV4cCI6MTYxOTcxNDM0MywiaXNzIjoiaHR0cHM6Ly9pZGVudGl0eS54ZXJvLmNvbSIsImF1ZCI6IkFEQjVBNzdEQTZCNjRFOTI4RDg0MDkwOTlBMzlDQTdCIiwiaWF0IjoxNjE5NzE0MDQzLCJhdF9oYXNoIjoiMXJNamVvUTJiOUxUNFU0ZlBXbEZJZyIsInNpZCI6ImY0YTY4ZDc0ZmM3OTQzMjc4YTgzMTg0NGM5ZWRmNzFiIiwic3ViIjoiZGI0ZjBmMzdiNTg1NTMwZTkxZjNiOWNiYjUwMzQwZTgiLCJhdXRoX3RpbWUiOjE2MTk3MTM5ODcsInhlcm9fdXNlcmlkIjoiZmFhODNlYzktZjZhNy00ODlmLTg5MTEtZTNmY2UwM2ExMTg2IiwiZ2xvYmFsX3Nlc3Npb25faWQiOiJmNGE2OGQ3NGZjNzk0MzI3OGE4MzE4NDRjOWVkZjcxYiIsInByZWZlcnJlZF91c2VybmFtZSI6ImNocmlzLmtuaWdodEB4ZXJvLmNvbSIsImVtYWlsIjoiY2hyaXMua25pZ2h0QHhlcm8uY29tIiwiZ2l2ZW5fbmFtZSI6IkNocmlzdG9waGVyIiwiZmFtaWx5X25hbWUiOiJLbmlnaHQifQ.hF04tCE1Qd-al355fQyCjWqTVWKnguor4RD1sC7rKH7zV3r3_nGwnGLMm2A96fov06fig0zusTX8onev0qFLZy-jlEXDp1f19LHhT15sBy0KH6dB0lGMrM14BnDuEP4NUGeP06nAPhQHHLw2oCc9hzYXorRVOSFDw43jgAC0vxRgDvJwgKgv6TDVEmpvwP0S4R7A0VbnFemHP_HY8gLHd7RpN7rrYmpJC4cofztdptDNLTF8Qup8qVlFdQgpJPQEQ95N1m6W-unvrh_dlO6AVMjXBjC1BJ10IGzoCCr8DSVyz2UMPnUT3oIYFVTlDc2K-ZJYkW86pigITMCdvR1hKg'} + let(:access_token){'eyJhbGciOiJSUzI1NiIsImtpZCI6IjFDQUY4RTY2NzcyRDZEQzAyOEQ2NzI2RkQwMjYxNTgxNTcwRUZDMTkiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJISy1PWm5jdGJjQW8xbkp2MENZVmdWY09fQmsifQ.eyJuYmYiOjE2MTk3MTQwNDMsImV4cCI6MTYxOTcxNTg0MywiaXNzIjoiaHR0cHM6Ly9pZGVudGl0eS54ZXJvLmNvbSIsImF1ZCI6Imh0dHBzOi8vaWRlbnRpdHkueGVyby5jb20vcmVzb3VyY2VzIiwiY2xpZW50X2lkIjoiQURCNUE3N0RBNkI2NEU5MjhEODQwOTA5OUEzOUNBN0IiLCJzdWIiOiJkYjRmMGYzN2I1ODU1MzBlOTFmM2I5Y2JiNTAzNDBlOCIsImF1dGhfdGltZSI6MTYxOTcxMzk4NywieGVyb191c2VyaWQiOiJmYWE4M2VjOS1mNmE3LTQ4OWYtODkxMS1lM2ZjZTAzYTExODYiLCJnbG9iYWxfc2Vzc2lvbl9pZCI6ImY0YTY4ZDc0ZmM3OTQzMjc4YTgzMTg0NGM5ZWRmNzFiIiwianRpIjoiZmFmNGNkYzQ5MjM0YzhmZDE0OTA0ZjRlOWEyMWY4YmYiLCJhdXRoZW50aWNhdGlvbl9ldmVudF9pZCI6IjI0MmRjNWIyLTIwZTMtNGFjNS05NjU3LWExMGI5ZTI0ZGI1NSIsInNjb3BlIjpbImVtYWlsIiwicHJvZmlsZSIsIm9wZW5pZCIsImFjY291bnRpbmcucmVwb3J0cy5yZWFkIiwiZmlsZXMiLCJwYXlyb2xsLmVtcGxveWVlcyIsInBheXJvbGwucGF5cnVucyIsInBheXJvbGwucGF5c2xpcCIsInBheXJvbGwudGltZXNoZWV0cyIsInByb2plY3RzLnJlYWQiLCJwcm9qZWN0cyIsImFjY291bnRpbmcuc2V0dGluZ3MiLCJhY2NvdW50aW5nLmF0dGFjaG1lbnRzIiwiYWNjb3VudGluZy50cmFuc2FjdGlvbnMiLCJhY2NvdW50aW5nLmpvdXJuYWxzLnJlYWQiLCJhc3NldHMucmVhZCIsImFzc2V0cyIsImFjY291bnRpbmcuY29udGFjdHMiLCJwYXlyb2xsLnNldHRpbmdzIiwib2ZmbGluZV9hY2Nlc3MiXX0.vNV-YsgHFWKFBmyYdhg7tztdsPc9ykObadQcGFoFXJ8qCBerR3h7XXKzWAP3KzFzhOCcIpWU8Q081zuYBNxahPeeLRLUuc_3MwgwE72esE5vGuxa2_-_QidtNvMCgsX-ie_LcX7FE_KI-sXB_EZ8fDk6WAMIPC9d3GejgeuH5Uh6rZkhowN2jm5pZjEOEy_QE7PScBO0XEbiZNUsarvBUSdKuSTvVVLHzHzs0bHMRfgKEkqZySNtZlac-oyaL3PVba1S7A_vbRcNWpYR_VrKGf2g9LHSI3EA5j3Beto4pKukU-bk6rLBGul37u4tM17U-wyJLsFmt6ZC_SEJKgmluQ'} + let(:tkn_set) {{'id_token': id_token, 'access_token': access_token, 'refresh_token': 'abc123xyz'}} + + before do + api_client.set_token_set(tkn_set) + end + + it '#token_expired? for an expired token' do + expect(api_client.token_expired?).to eq(true) + end + + it '#token_expired? for a just expired token' do + allow(api_client).to receive(:decoded_access_token).and_return({"exp"=>Time.now.to_i}) + expect(api_client.token_expired?).to eq(true) + end + + it '#token_expired? for a non-expired token' do + allow(api_client).to receive(:decoded_access_token).and_return({"exp"=>(Time.now + 30.minutes).to_i}) + expect(api_client.token_expired?).to eq(false) + end + + it '#token_expired? for an almost expired token' do + allow(api_client).to receive(:decoded_access_token).and_return({"exp"=>(Time.now + 30.seconds).to_i}) + expect(api_client.token_expired?).to eq(false) + end + + it '#validate_tokens' do + expect(api_client.validate_tokens(tkn_set)).to eq(true) + end + it '#access_token' do + expect(api_client.access_token).to eq(access_token) + end + it '#decoded_access_token' do + expect(api_client.decoded_access_token['aud']).to eq("https://identity.xero.com/resources") + end + it '#id_token' do + expect(api_client.id_token).to eq(tkn_set[:id_token]) + end + it '#decoded_id_token' do + expect(api_client.decoded_id_token['email']).to eq('chris.knight@xero.com') + end + + it 'decoding an invalid access_token' do + api_client.set_access_token("#{access_token}.NotAValidJWTstring") + expect{api_client.decoded_access_token}.to raise_error(JSON::JWT::InvalidFormat) + end + + it 'decoding an invalid id_token' do + api_client.set_id_token("#{id_token}.NotAValidJWTstring") + expect{api_client.decoded_id_token}.to raise_error(JSON::JWT::InvalidFormat) + end + end + + describe 'thread safety in the XeroClient' do let(:creds) {{ client_id: 'abc', @@ -383,8 +458,8 @@ let(:api_client_2) {XeroRuby::ApiClient.new(credentials: creds)} let(:api_client_3) {XeroRuby::ApiClient.new(credentials: creds)} - let(:tkn_set_1){{id_token: "abc.123.1", access_token: "xxx.yyy.zzz.111"}} - let(:tkn_set_2){{id_token: "efg.456.2", access_token: "xxx.yyy.zzz.222"}} + let(:tkn_set_1){{'id_token': "abc.123.1", 'access_token': "xxx.yyy.zzz.111"}} + let(:tkn_set_2){{'id_token': "efg.456.2", 'access_token': "xxx.yyy.zzz.222"}} describe 'when configuration is changed, other instantiations of the client are not affected' do it 'applies to #set_access_token' do @@ -426,12 +501,12 @@ expect(api_client_2.token_set).to eq(nil) api_client_1.set_token_set(tkn_set_1) - expect(api_client_1.token_set).to eq(tkn_set_1) + expect(api_client_1.token_set).to eq(tkn_set_1.with_indifferent_access) expect(api_client_2.token_set).to eq(nil) api_client_2.set_token_set(tkn_set_2) - expect(api_client_1.token_set).to eq(tkn_set_1) - expect(api_client_2.token_set).to eq(tkn_set_2) + expect(api_client_1.token_set).to eq(tkn_set_1.with_indifferent_access) + expect(api_client_2.token_set).to eq(tkn_set_2.with_indifferent_access) end it 'applies to #base_url' do diff --git a/xero-ruby.gemspec b/xero-ruby.gemspec index b200f125..484c5811 100644 --- a/xero-ruby.gemspec +++ b/xero-ruby.gemspec @@ -28,6 +28,7 @@ Gem::Specification.new do |s| s.add_runtime_dependency 'faraday', '~> 1.0', '>= 1.0.1' s.add_runtime_dependency 'json', '~> 2.1', '>= 2.1.0' + s.add_runtime_dependency 'json-jwt', '~> 1.5', '>= 1.5.2' s.add_development_dependency 'rspec', '~> 3.6', '>= 3.6.0' s.files = Dir.glob("{lib}/**/*") + %w(README.md)