Skip to content

Commit

Permalink
[TOOLSLIBS-1753] Adds Oauth support (#120)
Browse files Browse the repository at this point in the history
* adds token fetcher

* oauth and client changes

* oauth and client changes

* added key verify + exception

* adds tests, docs

* adds test, fixes typo

* fixes comment

* fixes comment

* restricts bundler version

* github workflow change

* github workflow change

* github workflow change

* changed variable name

* Nate's suggestions

* fixes tests

* added retry

* docs change

* adds power to retry

---------

Co-authored-by: Jade Westover <[email protected]>
  • Loading branch information
Jahdeh and Jade Westover committed May 7, 2024
1 parent dd02ff0 commit 2a6bb60
Show file tree
Hide file tree
Showing 9 changed files with 260 additions and 8 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/urbanairship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
23 changes: 20 additions & 3 deletions lib/urbanairship/client.rb
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
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

# Initialize the 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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lib/urbanairship/configuration.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
129 changes: 129 additions & 0 deletions lib/urbanairship/oauth.rb
Original file line number Diff line number Diff line change
@@ -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<String>] 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<String>] 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
24 changes: 24 additions & 0 deletions spec/lib/urbanairship/client_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'spec_helper'
require 'urbanairship/client'
require 'jwt'

describe Urbanairship::Client do
UA = Urbanairship
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions spec/lib/urbanairship/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 76 additions & 0 deletions spec/lib/urbanairship/oauth_spec.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion urbanairship.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit 2a6bb60

Please sign in to comment.