wsaa-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 398c92ee7bbeb19908d73033e371a8ee15c20e981eb377b26eccfa260d51c410
4
+ data.tar.gz: 7396411b2096967bd3113d77357b91c92bf64c41cc673993aa74bf2b1843ffd1
5
+ SHA512:
6
+ metadata.gz: 0335726e838104a2738869286ff3da6e5ec5fc128d8ea3f23094c624b9ccbcf95363cdb09502097210fa970fbe1fc86b618058be773e780c84afff45e57dbec0
7
+ data.tar.gz: 79beb07a9f78bc8ff61ae4e8f2d6508417de9903e02c92c835e998710035b7a36a09d3e00a5d0a88b6fac9cb05c7e64fec1e1877369edf00b34658810451ad44
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - Initial gem structure
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ # Pin rack to 2.x for httpi compatibility
6
+ gem 'rack', '~> 2.0'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Leandro Marcucci
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # WSAA Ruby
2
+
3
+ Ruby client for AFIP's WSAA (Web Service de Autenticación y Autorización).
4
+
5
+ WSAA is the authentication service required to access AFIP's web services in Argentina. This gem handles the login process to obtain TOKEN and SIGN credentials needed to call other AFIP services like WSFE (electronic invoicing).
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'wsaa-ruby'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```bash
24
+ gem install wsaa-ruby
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ruby
30
+ require 'wsaa'
31
+
32
+ # Configure the client
33
+ Wsaa.configure do |config|
34
+ config.pkey = '/path/to/private_key.pem'
35
+ config.cert = '/path/to/certificate.crt'
36
+ config.environment = :production # or :testing for homologation
37
+ config.service = 'wsfe' # the service you want to access
38
+ end
39
+
40
+ # Get authentication credentials
41
+ credentials = Wsaa.authenticate
42
+
43
+ credentials.token # => "PD94bWwg..."
44
+ credentials.sign # => "dGhpcyBp..."
45
+ ```
46
+
47
+ ## Documentation
48
+
49
+ - [WSAA Technical Specification (PDF)](https://www.afip.gov.ar/ws/WSAA/Especificacion_Tecnica_WSAA_1.2.0.pdf)
50
+
51
+ ## Development
52
+
53
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests.
54
+
55
+ ## Contributing
56
+
57
+ Bug reports and pull requests are welcome on GitHub.
58
+
59
+ ## License
60
+
61
+ The gem is available as open source under the terms of the [MIT License](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,93 @@
1
+ require 'savon'
2
+ require 'rexml/document'
3
+
4
+ module Wsaa
5
+ class Client
6
+ attr_reader :configuration
7
+
8
+ def initialize(configuration)
9
+ @configuration = configuration
10
+ end
11
+
12
+ def authenticate
13
+ configuration.validate!
14
+
15
+ cached = credential_store.read
16
+ return cached if cached
17
+
18
+ authenticate!
19
+ end
20
+
21
+ def authenticate!
22
+ configuration.validate!
23
+
24
+ tra = build_tra
25
+ signed_tra = sign_tra(tra.to_xml)
26
+ response = call_wsaa(signed_tra)
27
+ credentials = parse_response(response, tra.expiration_time)
28
+
29
+ credential_store.write(credentials)
30
+ credentials
31
+ end
32
+
33
+ private
34
+
35
+ def build_tra
36
+ Tra.new(service: configuration.service)
37
+ end
38
+
39
+ def sign_tra(tra_xml)
40
+ signer = CmsSigner.new(
41
+ cert_path: configuration.cert,
42
+ pkey_path: configuration.pkey
43
+ )
44
+ signer.sign(tra_xml)
45
+ end
46
+
47
+ def call_wsaa(signed_tra)
48
+ client = Savon.client(
49
+ wsdl: "#{configuration.endpoint}?WSDL",
50
+ endpoint: configuration.endpoint,
51
+ ssl_verify_mode: :none,
52
+ log: false
53
+ )
54
+
55
+ client.call(:login_cms, message: { in0: signed_tra })
56
+ rescue Savon::SOAPFault => e
57
+ raise AuthenticationError.new(
58
+ "WSAA authentication failed: #{e.message}",
59
+ fault_code: e.to_hash.dig(:fault, :faultcode),
60
+ fault_string: e.to_hash.dig(:fault, :faultstring)
61
+ )
62
+ rescue Savon::Error => e
63
+ raise AuthenticationError, "WSAA request failed: #{e.message}"
64
+ end
65
+
66
+ def parse_response(response, expiration_time)
67
+ login_cms_return = response.body.dig(:login_cms_response, :login_cms_return)
68
+
69
+ raise AuthenticationError, "Empty response from WSAA" if login_cms_return.nil?
70
+
71
+ doc = REXML::Document.new(login_cms_return)
72
+ token = doc.get_text('//token')&.to_s
73
+ sign = doc.get_text('//sign')&.to_s
74
+
75
+ if token.nil? || token.empty? || sign.nil? || sign.empty?
76
+ raise AuthenticationError, "Invalid response: missing token or sign"
77
+ end
78
+
79
+ Credentials.new(
80
+ token: token,
81
+ sign: sign,
82
+ expiration_time: expiration_time
83
+ )
84
+ end
85
+
86
+ def credential_store
87
+ @credential_store ||= CredentialStore.new(
88
+ cache_dir: configuration.cache_dir,
89
+ service: configuration.service
90
+ )
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,39 @@
1
+ require 'openssl'
2
+ require 'base64'
3
+
4
+ module Wsaa
5
+ class CmsSigner
6
+ attr_reader :certificate, :private_key
7
+
8
+ def initialize(cert_path:, pkey_path:)
9
+ @certificate = load_certificate(cert_path)
10
+ @private_key = load_private_key(pkey_path)
11
+ end
12
+
13
+ def sign(data)
14
+ flags = OpenSSL::PKCS7::BINARY | OpenSSL::PKCS7::NOSMIMECAP
15
+ pkcs7 = OpenSSL::PKCS7.sign(certificate, private_key, data, [], flags)
16
+ Base64.strict_encode64(pkcs7.to_der)
17
+ rescue OpenSSL::PKCS7::PKCS7Error => e
18
+ raise SigningError, "Failed to sign data: #{e.message}"
19
+ end
20
+
21
+ private
22
+
23
+ def load_certificate(path)
24
+ OpenSSL::X509::Certificate.new(File.read(path))
25
+ rescue OpenSSL::X509::CertificateError => e
26
+ raise SigningError, "Invalid certificate: #{e.message}"
27
+ rescue Errno::ENOENT
28
+ raise SigningError, "Certificate file not found: #{path}"
29
+ end
30
+
31
+ def load_private_key(path)
32
+ OpenSSL::PKey::RSA.new(File.read(path))
33
+ rescue OpenSSL::PKey::RSAError => e
34
+ raise SigningError, "Invalid private key: #{e.message}"
35
+ rescue Errno::ENOENT
36
+ raise SigningError, "Private key file not found: #{path}"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ module Wsaa
2
+ class Configuration
3
+ ENDPOINTS = {
4
+ testing: 'https://wsaahomo.afip.gov.ar/ws/services/LoginCms',
5
+ production: 'https://wsaa.afip.gov.ar/ws/services/LoginCms'
6
+ }.freeze
7
+
8
+ attr_accessor :pkey, :cert, :service, :environment, :cache_dir
9
+
10
+ def initialize
11
+ @environment = :testing
12
+ @service = 'wsfe'
13
+ @cache_dir = '/tmp'
14
+ end
15
+
16
+ def endpoint
17
+ ENDPOINTS.fetch(environment) do
18
+ raise ConfigurationError, "Invalid environment: #{environment}. Must be :testing or :production"
19
+ end
20
+ end
21
+
22
+ def validate!
23
+ raise ConfigurationError, "Private key path not configured" if pkey.nil? || pkey.empty?
24
+ raise ConfigurationError, "Certificate path not configured" if cert.nil? || cert.empty?
25
+ raise ConfigurationError, "Private key file not found: #{pkey}" unless File.exist?(pkey)
26
+ raise ConfigurationError, "Certificate file not found: #{cert}" unless File.exist?(cert)
27
+ true
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,58 @@
1
+ require 'yaml'
2
+ require 'time'
3
+
4
+ module Wsaa
5
+ class CredentialStore
6
+ attr_reader :cache_dir, :service
7
+
8
+ def initialize(cache_dir:, service:)
9
+ @cache_dir = cache_dir
10
+ @service = service
11
+ end
12
+
13
+ def read
14
+ return nil unless File.exist?(cache_file_path)
15
+
16
+ data = YAML.load_file(cache_file_path)
17
+ expiration_time = parse_expiration_time(data['expiration_time'])
18
+
19
+ credentials = Credentials.new(
20
+ token: data['token'],
21
+ sign: data['sign'],
22
+ expiration_time: expiration_time
23
+ )
24
+
25
+ credentials.valid? ? credentials : nil
26
+ rescue StandardError
27
+ nil
28
+ end
29
+
30
+ def write(credentials)
31
+ File.write(cache_file_path, YAML.dump(credentials.to_h.transform_keys(&:to_s)))
32
+ credentials
33
+ end
34
+
35
+ def clear
36
+ File.delete(cache_file_path) if File.exist?(cache_file_path)
37
+ end
38
+
39
+ def cache_file_path
40
+ File.join(cache_dir, cache_filename)
41
+ end
42
+
43
+ private
44
+
45
+ def cache_filename
46
+ date_str = Time.now.strftime('%d_%m_%Y')
47
+ "wsaa_#{service}_#{date_str}.yml"
48
+ end
49
+
50
+ def parse_expiration_time(value)
51
+ case value
52
+ when Time then value
53
+ when String then Time.parse(value)
54
+ else raise ArgumentError, "Invalid expiration_time: #{value}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,28 @@
1
+ module Wsaa
2
+ class Credentials
3
+ attr_reader :token, :sign, :expiration_time
4
+
5
+ def initialize(token:, sign:, expiration_time:)
6
+ @token = token.freeze
7
+ @sign = sign.freeze
8
+ @expiration_time = expiration_time
9
+ freeze
10
+ end
11
+
12
+ def expired?
13
+ Time.now > expiration_time
14
+ end
15
+
16
+ def valid?
17
+ !expired? && !token.nil? && !sign.nil?
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ token: token,
23
+ sign: sign,
24
+ expiration_time: expiration_time.iso8601
25
+ }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ module Wsaa
2
+ class Error < StandardError; end
3
+
4
+ class ConfigurationError < Error; end
5
+
6
+ class SigningError < Error; end
7
+
8
+ class AuthenticationError < Error
9
+ attr_reader :fault_code, :fault_string
10
+
11
+ def initialize(message, fault_code: nil, fault_string: nil)
12
+ @fault_code = fault_code
13
+ @fault_string = fault_string
14
+ super(message)
15
+ end
16
+ end
17
+ end
data/lib/wsaa/tra.rb ADDED
@@ -0,0 +1,50 @@
1
+ require 'rexml/document'
2
+
3
+ module Wsaa
4
+ class Tra
5
+ TIMEZONE_OFFSET = '-03:00'
6
+
7
+ attr_reader :service, :generation_time, :expiration_time, :unique_id
8
+
9
+ def initialize(service:, generation_time: nil, expiration_time: nil, unique_id: nil)
10
+ @service = service
11
+ @unique_id = unique_id || Time.now.to_i
12
+ @generation_time = generation_time || default_generation_time
13
+ @expiration_time = expiration_time || default_expiration_time
14
+ end
15
+
16
+ def to_xml
17
+ doc = REXML::Document.new
18
+ doc << REXML::XMLDecl.new('1.0', 'UTF-8')
19
+
20
+ root = doc.add_element('loginTicketRequest', 'version' => '1.0')
21
+
22
+ header = root.add_element('header')
23
+ header.add_element('uniqueId').text = unique_id.to_s
24
+ header.add_element('generationTime').text = format_time(generation_time)
25
+ header.add_element('expirationTime').text = format_time(expiration_time)
26
+
27
+ root.add_element('service').text = service
28
+
29
+ output = String.new
30
+ doc.write(output)
31
+ output
32
+ end
33
+
34
+ private
35
+
36
+ def default_generation_time
37
+ today = Time.now
38
+ Time.new(today.year, today.month, today.day, 0, 0, 0, TIMEZONE_OFFSET)
39
+ end
40
+
41
+ def default_expiration_time
42
+ today = Time.now
43
+ Time.new(today.year, today.month, today.day, 23, 59, 59, TIMEZONE_OFFSET)
44
+ end
45
+
46
+ def format_time(time)
47
+ time.strftime('%Y-%m-%dT%H:%M:%S%:z')
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,3 @@
1
+ module Wsaa
2
+ VERSION = '0.1.0'
3
+ end
data/lib/wsaa.rb ADDED
@@ -0,0 +1,39 @@
1
+ require_relative 'wsaa/version'
2
+ require_relative 'wsaa/errors'
3
+ require_relative 'wsaa/configuration'
4
+ require_relative 'wsaa/tra'
5
+ require_relative 'wsaa/cms_signer'
6
+ require_relative 'wsaa/credentials'
7
+ require_relative 'wsaa/credential_store'
8
+ require_relative 'wsaa/client'
9
+
10
+ module Wsaa
11
+ class << self
12
+ def configure
13
+ yield(configuration)
14
+ end
15
+
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def authenticate
21
+ client.authenticate
22
+ end
23
+
24
+ def authenticate!
25
+ client.authenticate!
26
+ end
27
+
28
+ def reset!
29
+ @configuration = nil
30
+ @client = nil
31
+ end
32
+
33
+ private
34
+
35
+ def client
36
+ @client ||= Client.new(configuration)
37
+ end
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wsaa-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Leandro Marcucci
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: savon
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ description: Ruby implementation of AFIP WSAA (Web Service de Autenticación y Autorización)
70
+ for authenticating with Argentine tax authority web services
71
+ email:
72
+ - leanucci@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - Gemfile
79
+ - LICENSE
80
+ - README.md
81
+ - Rakefile
82
+ - lib/wsaa.rb
83
+ - lib/wsaa/client.rb
84
+ - lib/wsaa/cms_signer.rb
85
+ - lib/wsaa/configuration.rb
86
+ - lib/wsaa/credential_store.rb
87
+ - lib/wsaa/credentials.rb
88
+ - lib/wsaa/errors.rb
89
+ - lib/wsaa/tra.rb
90
+ - lib/wsaa/version.rb
91
+ homepage: https://github.com/leanucci/wsaa-ruby
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ homepage_uri: https://github.com/leanucci/wsaa-ruby
96
+ source_code_uri: https://github.com/leanucci/wsaa-ruby
97
+ changelog_uri: https://github.com/leanucci/wsaa-ruby/blob/main/CHANGELOG.md
98
+ post_install_message:
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: 2.7.0
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.1.6
114
+ signing_key:
115
+ specification_version: 4
116
+ summary: Ruby client for AFIP WSAA authentication service
117
+ test_files: []