diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1c21d80..f392c38 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,6 +17,9 @@ jobs: uses: ruby/setup-ruby@477b21f02be01bcb8030d50f37cfec92bfa615b6 with: ruby-version: ${{ matrix.ruby-version }} + bundler: none + - name: Install bundler 2.4 + run: gem install bundler -v 2.4.22 - name: Install dependencies run: bundle install - name: Run tests diff --git a/lib/urbanairship.rb b/lib/urbanairship.rb index 068d9b8..6d4d0b6 100644 --- a/lib/urbanairship.rb +++ b/lib/urbanairship.rb @@ -15,6 +15,7 @@ require 'urbanairship/devices/attribute' require 'urbanairship/devices/attributes' require 'urbanairship/client' +require 'urbanairship/oauth' require 'urbanairship/common' require 'urbanairship/configuration' require 'urbanairship/loggable' diff --git a/lib/urbanairship/client.rb b/lib/urbanairship/client.rb index bf3b1e5..6ba172e 100644 --- a/lib/urbanairship/client.rb +++ b/lib/urbanairship/client.rb @@ -1,11 +1,12 @@ require 'json' require 'rest-client' require 'urbanairship' +require 'jwt' module Urbanairship class Client - attr_accessor :key, :secret + attr_accessor :key, :secret, :token include Urbanairship::Common include Urbanairship::Loggable @@ -13,15 +14,21 @@ class Client # # @param [Object] key Application Key # @param [Object] secret Application Secret - # @param [String] server Airship server to use ("go.airship.eu" or "go.urbanairship.com"). + # @param [String] server Airship server to use ("api.asnapieu.com" for EU or "api.asnapius.com" for US). # Used only when the request is sent with a "path", not an "url". # @param [String] token Application Auth Token + # @param [Object] oauth Oauth object # @return [Object] Client - def initialize(key: required('key'), secret: nil, server: Urbanairship.configuration.server, token: nil) + def initialize(key: required('key'), secret: nil, server: Urbanairship.configuration.server, token: nil, oauth: nil) @key = key @secret = secret @server = server @token = token + @oauth = oauth + + if @oauth != nil && @token != nil + raise ArgumentError.new("oauth and token can't both be used at the same time.") + end end # Send a request to Airship's API @@ -56,6 +63,16 @@ def send_request(method: required('method'), path: nil, url: nil, body: nil, headers['Content-Type'] = content_type unless content_type.nil? headers['Content-Encoding'] = encoding unless encoding.nil? + unless @oauth.nil? + begin + @token = @oauth.get_token + rescue RestClient::Exception => e + new_error = RestClient::Exception.new(e.response, e.response.code) + new_error.message = "error while getting oauth token: #{e.message}" + raise new_error + end + end + if @token != nil auth_type = :bearer end diff --git a/lib/urbanairship/configuration.rb b/lib/urbanairship/configuration.rb index dd3db20..ef3d788 100644 --- a/lib/urbanairship/configuration.rb +++ b/lib/urbanairship/configuration.rb @@ -1,9 +1,10 @@ module Urbanairship class Configuration - attr_accessor :custom_logger, :log_path, :log_level, :server, :timeout + attr_accessor :custom_logger, :log_path, :log_level, :server, :oauth_server, :timeout def initialize - @server = 'go.urbanairship.com' + @server = 'api.asnapius.com' + @oauth_server = 'oauth2.asnapius.com' @custom_logger = nil @log_path = nil @log_level = Logger::INFO diff --git a/lib/urbanairship/oauth.rb b/lib/urbanairship/oauth.rb new file mode 100644 index 0000000..fe9d300 --- /dev/null +++ b/lib/urbanairship/oauth.rb @@ -0,0 +1,129 @@ +require 'urbanairship' +require 'base64' +require 'rest-client' + +module Urbanairship + class Oauth + attr_accessor :client_id, :sub, :assertion_private_key, :ip_addresses, :scopes, :oauth_server + + # Initialize Oauth class + # + # @param [String] client_id The Client ID found when creating Oauth credentials in the dashboard. + # @param [String] key The app key for the project. + # @param [String] assertion_private_key The private key found when creating Oauth credentials in the dashboard. Used for assertion token auth. + # @param [Array] ip_addresses A list of CIDR representations of valid IP addresses to which the issued token is restricted. Example: ['24.20.40.0/22', '34.17.3.0/22'] + # @param [Array] scopes A list of scopes to which the issued token will be entitled. Example: ['psh', 'lst'] + # @param [String] oauth_server The server to send Oauth token requests to. By default is 'oauth2.asnapius.com', but can be set to 'oauth2.asnapieu.com' if using the EU server. + # @return [Object] Oauth object + def initialize(client_id:, key:, assertion_private_key:, ip_addresses: [], scopes: [], oauth_server: Urbanairship.configuration.oauth_server) + @grant_type = 'client_credentials' + @client_id = client_id + @assertion_private_key = assertion_private_key + @ip_addresses = ip_addresses + @scopes = scopes + @sub = "app:#{key}" + @oauth_server = oauth_server + @token = nil + end + + # Get an Oauth token from Airship Oauth servers. + # + # @return [String] JSON web token to be used in further Airship API requests. + def get_token + unless @token.nil? + decoded_jwt = JWT.decode(@token, nil, false) + current_time = Time.now.to_i + expiry_time = decoded_jwt[0]['exp'] + + if current_time < expiry_time + return @token + end + end + + assertion_jwt = build_assertion_jwt + + url = "https://#{@oauth_server}/token" + headers = { + 'Host': @oauth_server, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + + params = { + method: :post, + url: url, + headers: headers, + payload: { + grant_type: @grant_type, + assertion: assertion_jwt + }, + timeout: 60 + } + + retries = 0 + max_retries = 3 + begin + response = RestClient::Request.execute(params) + @token = JSON.parse(response.body)['access_token'] + return @token + rescue RestClient::ExceptionWithResponse => e + if [400, 401, 406].include?(e.response.code) + raise e + else + retries += 1 + if retries <= max_retries + sleep(retries ** 2) + retry + else + new_error = RestClient::Exception.new(e.response, e.response.code) + new_error.message = "failed after 3 attempts with error: #{e}" + raise new_error + end + end + end + end + + # Build an assertion JWT + # + # @return [String] Assertion JWT to be used when requesting an Oauth token from Airship servers. + def build_assertion_jwt + assertion_expiration = 61 + private_key = OpenSSL::PKey::EC.new(@assertion_private_key) + + headers = { + alg: 'ES384', + kid: @client_id + } + + claims = { + aud: "https://#{@oauth_server}/token", + exp: Time.now.to_i + assertion_expiration, + iat: Time.now.to_i, + iss: @client_id, + nonce: SecureRandom.uuid, + sub: @sub + } + + claims[:scope] = @scopes.join(' ') if @scopes.any? + claims[:ipaddr] = @ip_addresses.join(' ') if @ip_addresses.any? + + JWT.encode(claims, private_key, 'ES384', headers) + end + + # Verify a public key + # + # @param [String] key_id The key ID ('kid') found in the header when decoding an Oauth token granted from Airship's servers. + # @return [String] The public key associated with the Key ID. + def verify_public_key(key_id) + url = "https://#{@oauth_server}/verify/public_key/#{key_id}" + + headers = { + 'Host': @oauth_server, + 'Accept': 'text/plain' + } + + response = RestClient.get(url, headers) + response.body + end + end +end diff --git a/spec/lib/urbanairship/client_spec.rb b/spec/lib/urbanairship/client_spec.rb index 62e0c74..100d0dd 100644 --- a/spec/lib/urbanairship/client_spec.rb +++ b/spec/lib/urbanairship/client_spec.rb @@ -1,5 +1,6 @@ require 'spec_helper' require 'urbanairship/client' +require 'jwt' describe Urbanairship::Client do UA = Urbanairship @@ -104,4 +105,27 @@ ua_client = UA::Client.new(key: '123', token: 'test-token') ua_client.send_request(method: 'POST', path: UA.push_path) end + + it 'is instantiated with oauth' do + oauth = UA::Oauth.new(client_id: 'client123', key: '123', assertion_private_key: 'test') + ua_client = UA::Client.new(key: '123', oauth: oauth) + expect(ua_client).not_to be_nil + end + + it 'creates token with oauth' do + token = 'test token' + + mock_response = double('response') + allow(mock_response).to(receive_messages(code: 200, headers: '', body: '{}')) + + oauth = UA::Oauth.new(client_id: 'client123', key: '123', assertion_private_key: 'secret123') + ua_client = UA::Client.new(key: '123', oauth: oauth) + allow(oauth).to receive(:get_token).and_return(token) + allow(JWT).to receive(:decode).and_return([{'exp'=> Time.now.to_i + 3600}]) + + allow(RestClient::Request).to(receive(:execute)).and_return(mock_response) + ua_client.send_request(method: 'POST', path: UA.push_path) + + expect(ua_client.token == token) + end end diff --git a/spec/lib/urbanairship/configuration_spec.rb b/spec/lib/urbanairship/configuration_spec.rb index c007569..d394bda 100644 --- a/spec/lib/urbanairship/configuration_spec.rb +++ b/spec/lib/urbanairship/configuration_spec.rb @@ -6,9 +6,9 @@ subject(:config) { described_class.new } describe '#base_url' do - let(:default_server) { 'go.urbanairship.com' } + let(:default_server) { 'api.asnapius.com' } - it 'initializes with the original value "go.urbanairship.com"' do + it 'initializes with the original value "api.asnapius.com"' do expect(config.server).to eq(default_server) end diff --git a/spec/lib/urbanairship/oauth_spec.rb b/spec/lib/urbanairship/oauth_spec.rb new file mode 100644 index 0000000..e661bc4 --- /dev/null +++ b/spec/lib/urbanairship/oauth_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' +require 'urbanairship/oauth' + +describe Urbanairship::Oauth do + UA = Urbanairship + + it 'is instantiated with Oauth assertion auth' do + oauth = UA::Oauth.new( + client_id: 'hf73hfh_test_client_id_83hrg', + assertion_private_key: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----', + key: '37djhf_test_app_key_ndf8h3' + ) + expect(oauth).not_to be_nil + end + + it 'requests a token using assertion auth' do + assertion_jwt = 'test_assertion' + private_key = '-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAyfZQIiXQwXabABKqV +LWU/Yek+jz/OIdEMK4nvaa77/nNTc6WgzudKityW09PuJIKhZANiAATaKO7pdTRk +NDqMIFjtTILog5pfX+OZkrMr+2i3VoQoiFwzJO0fh0xCJ2Lg1l7nYIOCs09/deb1 +fwMOSxoXG/IMD3AqqwqZzRmgeKfnupueqO3RNxngJUL+0zQTW+dSXWk= +-----END PRIVATE KEY----- +' + + oauth = UA::Oauth.new(client_id: 'test123', key: 'testappkey', assertion_private_key: private_key) + + request_params = { + payload: { + assertion: assertion_jwt, + grant_type: "client_credentials" + } + } + + allow(oauth).to receive(:build_assertion_jwt).and_return(assertion_jwt) + + mock_response = double('response') + allow(mock_response).to(receive_messages(code: 200, headers: '', body: '{"access_token": "mock_token"}')) + expect(RestClient::Request).to(receive(:execute).with(hash_including(request_params))).and_return(mock_response) + + token = oauth.get_token + expect(token).to eq("mock_token") + end + + it 'builds an assertion jwt' do + # This is a private key from revoked oauth credentials on a test app. + # An actual private key is required to test this properly. + private_key = '-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAyfZQIiXQwXabABKqV +LWU/Yek+jz/OIdEMK4nvaa77/nNTc6WgzudKityW09PuJIKhZANiAATaKO7pdTRk +NDqMIFjtTILog5pfX+OZkrMr+2i3VoQoiFwzJO0fh0xCJ2Lg1l7nYIOCs09/deb1 +fwMOSxoXG/IMD3AqqwqZzRmgeKfnupueqO3RNxngJUL+0zQTW+dSXWk= +-----END PRIVATE KEY----- +' + + oauth = UA::Oauth.new(client_id: 'test123', key: 'testappkey', assertion_private_key: private_key) + assertion_jwt = oauth.build_assertion_jwt + + expect(assertion_jwt).not_to be_nil + end + + it 'should retrieve a public key' do + oauth = UA::Oauth.new(client_id: 'test123', key: 'testappkey', assertion_private_key: 'testsecret') + key_id = 'test123' + public_key = 'test_public_key' + server = 'https://oauth2.asnapius.com/verify/public_key/test123' + + mock_response = double('response') + allow(mock_response).to(receive_messages(code: 200, headers: '', body: public_key)) + expect(RestClient).to(receive(:get).with(server, {'Host': oauth.oauth_server, 'Accept': 'text/plain'})) + .and_return(mock_response) + + public_key = oauth.verify_public_key(key_id) + expect(public_key).to eq("test_public_key") + end +end \ No newline at end of file diff --git a/urbanairship.gemspec b/urbanairship.gemspec index 88a73ed..510f039 100644 --- a/urbanairship.gemspec +++ b/urbanairship.gemspec @@ -28,8 +28,9 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.add_runtime_dependency 'rest-client', '>= 1.4', '< 4.0' + spec.add_runtime_dependency 'jwt', '>= 2.0', '< 3.0' - spec.add_development_dependency 'bundler', '>= 1' + spec.add_development_dependency 'bundler', '>= 1', '< 2.5' spec.add_development_dependency 'guard-rspec' spec.add_development_dependency 'pry', '~> 0' spec.add_development_dependency 'rake', '~> 12.3.3'