diff --git a/.gitignore b/.gitignore index 6a520b6..3615ece 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,10 @@ Gemfile.lock *.crt *.p12 +# Allow test fixtures +!spec/fixtures/test_key.pem +!spec/fixtures/test_cert.pem + # Project specific wsaa-ruby-cert wsaa-ruby-csr diff --git a/CHANGELOG.md b/CHANGELOG.md index e608c54..bc58837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-08-18 + +### Added +- RDoc documentation for all public classes and methods +- GitHub Actions CI workflow for running tests on PRs +- GitHub Actions release workflow for publishing to RubyGems + ## [0.1.0] - 2026-08-18 ### Added diff --git a/lib/wsaa.rb b/lib/wsaa.rb index 849f646..e4b2dbc 100644 --- a/lib/wsaa.rb +++ b/lib/wsaa.rb @@ -7,24 +7,68 @@ require_relative 'wsaa/credential_store' require_relative 'wsaa/client' +## +# Main module for WSAA authentication. +# +# Provides a simple interface to authenticate with AFIP's WSAA service. +# +# @example Configure and authenticate +# Wsaa.configure do |config| +# config.pkey = 'path/to/private_key' +# config.cert = 'path/to/certificate' +# config.service = 'wsfe' +# config.environment = :testing +# end +# +# credentials = Wsaa.authenticate +# credentials.token # => "PD94bWwg..." +# credentials.sign # => "GGG2XMe..." module Wsaa class << self + ## + # Configures the WSAA client. + # + # @yield [Configuration] The configuration object. + # + # @example + # Wsaa.configure do |config| + # config.pkey = 'path/to/key' + # config.cert = 'path/to/cert' + # end def configure yield(configuration) end + ## + # Returns the current configuration. + # + # @return [Configuration] The configuration object. def configuration @configuration ||= Configuration.new end + ## + # Authenticates with WSAA using cached credentials if available. + # + # @return [Credentials] The authentication credentials. + # @raise [ConfigurationError] If the configuration is invalid. + # @raise [AuthenticationError] If authentication fails. def authenticate client.authenticate end + ## + # Authenticates with WSAA, ignoring any cached credentials. + # + # @return [Credentials] The authentication credentials. + # @raise [ConfigurationError] If the configuration is invalid. + # @raise [AuthenticationError] If authentication fails. def authenticate! client.authenticate! end + ## + # Resets the configuration and client state. def reset! @configuration = nil @client = nil diff --git a/lib/wsaa/client.rb b/lib/wsaa/client.rb index a2e1b0b..79bb2ef 100644 --- a/lib/wsaa/client.rb +++ b/lib/wsaa/client.rb @@ -2,13 +2,29 @@ require 'rexml/document' module Wsaa + ## + # WSAA authentication client. + # + # Orchestrates the authentication flow: builds TRA, signs it, calls WSAA, and caches credentials. + # + # @attr_reader configuration [Configuration] The client configuration. class Client attr_reader :configuration + ## + # Creates a new Client. + # + # @param configuration [Configuration] The client configuration. def initialize(configuration) @configuration = configuration end + ## + # Authenticates with WSAA using cached credentials if available. + # + # @return [Credentials] The authentication credentials. + # @raise [ConfigurationError] If the configuration is invalid. + # @raise [AuthenticationError] If authentication fails. def authenticate configuration.validate! @@ -18,6 +34,12 @@ def authenticate authenticate! end + ## + # Authenticates with WSAA, ignoring any cached credentials. + # + # @return [Credentials] The authentication credentials. + # @raise [ConfigurationError] If the configuration is invalid. + # @raise [AuthenticationError] If authentication fails. def authenticate! configuration.validate! diff --git a/lib/wsaa/cms_signer.rb b/lib/wsaa/cms_signer.rb index 9434073..90ac9ec 100644 --- a/lib/wsaa/cms_signer.rb +++ b/lib/wsaa/cms_signer.rb @@ -2,14 +2,33 @@ require 'base64' module Wsaa + ## + # Signs data using CMS/PKCS#7 format. + # + # Creates cryptographic signatures compatible with WSAA requirements. + # + # @attr_reader certificate [OpenSSL::X509::Certificate] The signing certificate. + # @attr_reader private_key [OpenSSL::PKey::RSA] The private key for signing. class CmsSigner attr_reader :certificate, :private_key + ## + # Creates a new CmsSigner. + # + # @param cert_path [String] Path to the certificate file in PEM format. + # @param pkey_path [String] Path to the private key file in PEM format. + # @raise [SigningError] If the certificate or key cannot be loaded. def initialize(cert_path:, pkey_path:) @certificate = load_certificate(cert_path) @private_key = load_private_key(pkey_path) end + ## + # Signs data and returns the base64-encoded CMS signature. + # + # @param data [String] The data to sign. + # @return [String] Base64-encoded PKCS#7/CMS signature. + # @raise [SigningError] If signing fails. def sign(data) flags = OpenSSL::PKCS7::BINARY | OpenSSL::PKCS7::NOSMIMECAP pkcs7 = OpenSSL::PKCS7.sign(certificate, private_key, data, [], flags) diff --git a/lib/wsaa/configuration.rb b/lib/wsaa/configuration.rb index e9f7ce0..de94842 100644 --- a/lib/wsaa/configuration.rb +++ b/lib/wsaa/configuration.rb @@ -1,4 +1,12 @@ module Wsaa + ## + # Holds configuration for the WSAA client. + # + # @attr_accessor pkey [String] Path to the private key file. + # @attr_accessor cert [String] Path to the certificate file. + # @attr_accessor service [String] The AFIP service to authenticate for. + # @attr_accessor environment [Symbol] The environment (:testing or :production). + # @attr_accessor cache_dir [String] Directory for credential caching. class Configuration ENDPOINTS = { testing: 'https://wsaahomo.afip.gov.ar/ws/services/LoginCms', @@ -7,18 +15,30 @@ class Configuration attr_accessor :pkey, :cert, :service, :environment, :cache_dir + ## + # Creates a new Configuration with default values. def initialize @environment = :testing @service = 'wsfe' @cache_dir = '/tmp' end + ## + # Returns the WSAA endpoint URL for the current environment. + # + # @return [String] The endpoint URL. + # @raise [ConfigurationError] If the environment is invalid. def endpoint ENDPOINTS.fetch(environment) do raise ConfigurationError, "Invalid environment: #{environment}. Must be :testing or :production" end end + ## + # Validates the configuration. + # + # @return [true] If the configuration is valid. + # @raise [ConfigurationError] If required values are missing or files do not exist. def validate! raise ConfigurationError, "Private key path not configured" if pkey.nil? || pkey.empty? raise ConfigurationError, "Certificate path not configured" if cert.nil? || cert.empty? diff --git a/lib/wsaa/credential_store.rb b/lib/wsaa/credential_store.rb index db660db..026469b 100644 --- a/lib/wsaa/credential_store.rb +++ b/lib/wsaa/credential_store.rb @@ -2,14 +2,30 @@ require 'time' module Wsaa + ## + # File-based cache for WSAA credentials. + # + # Stores credentials as YAML files with date-based filenames. + # + # @attr_reader cache_dir [String] Directory where cache files are stored. + # @attr_reader service [String] The service name used in the cache filename. class CredentialStore attr_reader :cache_dir, :service + ## + # Creates a new CredentialStore. + # + # @param cache_dir [String] Directory for cache files. + # @param service [String] The service name. def initialize(cache_dir:, service:) @cache_dir = cache_dir @service = service end + ## + # Reads cached credentials. + # + # @return [Credentials, nil] The cached credentials, or nil if not found or expired. def read return nil unless File.exist?(cache_file_path) @@ -27,15 +43,26 @@ def read nil end + ## + # Writes credentials to the cache. + # + # @param credentials [Credentials] The credentials to cache. + # @return [Credentials] The same credentials object. def write(credentials) File.write(cache_file_path, YAML.dump(credentials.to_h.transform_keys(&:to_s))) credentials end + ## + # Deletes the cache file. def clear File.delete(cache_file_path) if File.exist?(cache_file_path) end + ## + # Returns the full path to the cache file. + # + # @return [String] The cache file path. def cache_file_path File.join(cache_dir, cache_filename) end diff --git a/lib/wsaa/credentials.rb b/lib/wsaa/credentials.rb index 0882336..1c9afe8 100644 --- a/lib/wsaa/credentials.rb +++ b/lib/wsaa/credentials.rb @@ -1,7 +1,21 @@ module Wsaa + ## + # Immutable value object for WSAA authentication credentials. + # + # Holds the TOKEN and SIGN values returned by WSAA after successful authentication. + # + # @attr_reader token [String] The authentication token. + # @attr_reader sign [String] The authentication signature. + # @attr_reader expiration_time [Time] When the credentials expire. class Credentials attr_reader :token, :sign, :expiration_time + ## + # Creates new Credentials. + # + # @param token [String] The authentication token from WSAA. + # @param sign [String] The authentication signature from WSAA. + # @param expiration_time [Time] When the credentials expire. def initialize(token:, sign:, expiration_time:) @token = token.freeze @sign = sign.freeze @@ -9,14 +23,26 @@ def initialize(token:, sign:, expiration_time:) freeze end + ## + # Checks if the credentials have expired. + # + # @return [Boolean] True if expired. def expired? Time.now > expiration_time end + ## + # Checks if the credentials are valid. + # + # @return [Boolean] True if not expired and has token and sign. def valid? !expired? && !token.nil? && !sign.nil? end + ## + # Converts credentials to a hash. + # + # @return [Hash] Hash with :token, :sign, and :expiration_time keys. def to_h { token: token, diff --git a/lib/wsaa/errors.rb b/lib/wsaa/errors.rb index cd6759e..22d4b82 100644 --- a/lib/wsaa/errors.rb +++ b/lib/wsaa/errors.rb @@ -1,13 +1,30 @@ module Wsaa + ## + # Base error class for all WSAA errors. class Error < StandardError; end + ## + # Raised when the configuration is invalid or incomplete. class ConfigurationError < Error; end + ## + # Raised when CMS/PKCS#7 signing fails. class SigningError < Error; end + ## + # Raised when WSAA authentication fails. + # + # @attr_reader fault_code [String, nil] The SOAP fault code from WSAA. + # @attr_reader fault_string [String, nil] The SOAP fault message from WSAA. class AuthenticationError < Error attr_reader :fault_code, :fault_string + ## + # Creates a new AuthenticationError. + # + # @param message [String] The error message. + # @param fault_code [String, nil] The SOAP fault code. + # @param fault_string [String, nil] The SOAP fault message. def initialize(message, fault_code: nil, fault_string: nil) @fault_code = fault_code @fault_string = fault_string diff --git a/lib/wsaa/tra.rb b/lib/wsaa/tra.rb index 35bc666..02aefcf 100644 --- a/lib/wsaa/tra.rb +++ b/lib/wsaa/tra.rb @@ -1,11 +1,27 @@ require 'rexml/document' module Wsaa + ## + # Builds the Ticket de Requerimiento de Acceso (TRA) XML document. + # + # The TRA is the access request ticket required by WSAA for authentication. + # + # @attr_reader service [String] The AFIP service name. + # @attr_reader generation_time [Time] When the TRA was generated. + # @attr_reader expiration_time [Time] When the TRA expires. + # @attr_reader unique_id [Integer] Unique identifier for the request. class Tra TIMEZONE_OFFSET = '-03:00' attr_reader :service, :generation_time, :expiration_time, :unique_id + ## + # Creates a new TRA. + # + # @param service [String] The AFIP service to authenticate for. + # @param generation_time [Time, nil] Start of validity period. Defaults to today 00:00:00. + # @param expiration_time [Time, nil] End of validity period. Defaults to today 23:59:59. + # @param unique_id [Integer, nil] Unique request identifier. Defaults to current timestamp. def initialize(service:, generation_time: nil, expiration_time: nil, unique_id: nil) @service = service @unique_id = unique_id || Time.now.to_i @@ -13,6 +29,10 @@ def initialize(service:, generation_time: nil, expiration_time: nil, unique_id: @expiration_time = expiration_time || default_expiration_time end + ## + # Converts the TRA to an XML string. + # + # @return [String] The TRA as XML. def to_xml doc = REXML::Document.new doc << REXML::XMLDecl.new('1.0', 'UTF-8') diff --git a/lib/wsaa/version.rb b/lib/wsaa/version.rb index 579f6cf..f4e52f3 100644 --- a/lib/wsaa/version.rb +++ b/lib/wsaa/version.rb @@ -1,3 +1,5 @@ module Wsaa - VERSION = '0.1.0' + ## + # Current version of the wsaa-ruby gem. + VERSION = '0.2.0' end diff --git a/spec/fixtures/test_cert.pem b/spec/fixtures/test_cert.pem new file mode 100644 index 0000000..8de95aa --- /dev/null +++ b/spec/fixtures/test_cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC0jCCAboCCQCvzoFKT3g1QTANBgkqhkiG9w0BAQsFADArMQ0wCwYDVQQDDARU +ZXN0MQ0wCwYDVQQKDARUZXN0MQswCQYDVQQGEwJBUjAeFw0yNjA3MzAxODQ3MTFa +Fw0yNzA3MzAxODQ3MTFaMCsxDTALBgNVBAMMBFRlc3QxDTALBgNVBAoMBFRlc3Qx +CzAJBgNVBAYTAkFSMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA690r +mICAKTk+248l61fQchT/fUhMTwhHTmpzREuOzP/G7CN3HBNGF/GcQwczalQFzFFx +3jeKI/TqsxFdFXzWZ4Va8kXXkAbVpxfN2vXV2bInX/z+03QgtSHlH88rKUrwUpqK +lE8k+4d7rf8lBwWArm6od488pV/usJXlj2WyHHjKg1xYQ4ylUSVPY3JvfdhbNAfL +TucDPuMDeR059rPVmvyUDQvJaVee/SCVwaxAc4fP4Bd30NkxhxVEYpDoZdJqhEK0 +cuM/nGIWUPeqiZuygOcTsqOdErV7/uNYdGncfz7u7u9ul+gyrUcU2D9eZfY7Cxii +vpM0wEalZwL80RRhYQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBTHcHKWRuHUuuf +93tHwR1Jx9tqnpZ4y9YBXxVeB0LRRasM32PwVyqGE1BnpIeF3UKcm80aCT62bps8 +6mIpPUAy9mZf8Mdr8tj/fDyog5OUmFr5HbpccIg8RjKFBP+8S2quOYp31RohoeuT +i+kSKSLz84j0jNcml1Kzcp9hNggAN7HAwIb4JASNzwHtxIv+2YrTILma570N+7SD +oSN6c7N2jq++n4HsfvBn1LN0mgkybeJxWgFS58OkWF56y74GMD/RSFFRpD6IAqXX +TY8i4KCwcOctYrxCmA2yVx1jiEadC8B7lsTKg2UbOIlFG2tWzyPnjOkF/OkYtKnM ++AjCLAq4 +-----END CERTIFICATE----- diff --git a/spec/fixtures/test_key.pem b/spec/fixtures/test_key.pem new file mode 100644 index 0000000..68e86ce --- /dev/null +++ b/spec/fixtures/test_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA690rmICAKTk+248l61fQchT/fUhMTwhHTmpzREuOzP/G7CN3 +HBNGF/GcQwczalQFzFFx3jeKI/TqsxFdFXzWZ4Va8kXXkAbVpxfN2vXV2bInX/z+ +03QgtSHlH88rKUrwUpqKlE8k+4d7rf8lBwWArm6od488pV/usJXlj2WyHHjKg1xY +Q4ylUSVPY3JvfdhbNAfLTucDPuMDeR059rPVmvyUDQvJaVee/SCVwaxAc4fP4Bd3 +0NkxhxVEYpDoZdJqhEK0cuM/nGIWUPeqiZuygOcTsqOdErV7/uNYdGncfz7u7u9u +l+gyrUcU2D9eZfY7CxiivpM0wEalZwL80RRhYQIDAQABAoIBAHt55KnIk8+A9YQx +s3M6HJisn3fx0mkJd3L4zXEycQam3HegtWlQOcCtLtX0Phlq9UIaXRctUjcyr2+h +Jm7qVMALEyqEH655tX3tO0pmqlZ2KqDYrHr8b2cD4JQ/e2P5pEhKOUQdDspTCg9z +4+mCGvS2X6F/rgsRZr6xKmVgota98EzDiAaHQLaEOfEAv54T7EUiwL7p/6ai0t5b +k9VODAisrUriFfAIX2enzILVfP1vBLQlzTUeEww6fYRCLpNwqSRnLjB9G6zvR41D +3qLMeOKQOGqDtfmr/2fNfJfn2GuX98doY9LnJ+7ramPGmqOtCsFrH0AzYbO8kOQC +ze6Sg2UCgYEA+gXhOwqnqO+kUdXWfSHd1oxBWGCB7RNO+eZGu14UfcUJBJh+C68w +7iV358/wOXm4aAF4ermo1pM/M5oBHDLTRXG5zXAtzPNUvdGdhRAMkhcL62AEK8Ip +PO0BumlccYWPXEP3XUTX3U61qUe86y5QkCxqP9ENx1KHBY2++KABsFsCgYEA8YCj +cC4LEWyfQjxHsXO/SREM+/0FgbteARLO9PXdDEoEzAJrEYM3bm0wFXggFq7dph7Y +wtp4xNSPhJ6CqLULCMUkAnqslRDC4BP/vCRU9z9iRbVgSp2tt/EYigsMlGntgkWc +ATZy32DqdQY9HSF9Erly4AyIyxnJ4wsmFLOO4fMCgYAyqMRVMTh9e2kBB+H44sbP +NQX8gTlyLupeqjEEv/BI94Z3PuqzNWElr8PW3YQgMTCTKlEeFMZIC/fPXdIBw66Z +MIzkOmmdUGSIPZO8JV9WroLEw2S09Rdj9+XUvopzmgtNN5xEcrGGbu9SCG4X/P9n +5wf0PEidbXRcRLdAKzOMZwKBgH409LSmS0By3JOXvvu8eoWrtHZn1x3iFhFHjAI2 +DqvibrlStjNzuF8sDonZuiNAhTLQQgUqeRyb3Ni4oZ2a50ZRCC2HgHNyS/UgXnAr +KIABjtDRJZ444tUS2PkjJxLUfLustwdnNgcItd4sQXncy2kioyb1RfOl6XyOkfsV +jyEDAoGBAKpU7MNKGUwAL+Ka5296vxHCGkL/uGoEZHC2dS07HCKurMa5i8RhyaNL +m5WRdyCW/+HsgPhz3XOz6vGkjlx5tt/9UnMMZWRgUAGLSAjwS+Ujm4nJSckZQPyh +A8HpLR0eXt1D2fIlEUvtNn0mza6Oac8wx6BdMOW8nXUgf0Q1Wxlc +-----END RSA PRIVATE KEY----- diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index cddd18c..991ff75 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,15 @@ require 'bundler/setup' require 'wsaa' +require 'webmock/rspec' +require 'vcr' + +VCR.configure do |config| + config.cassette_library_dir = 'spec/fixtures/vcr_cassettes' + config.hook_into :webmock + config.configure_rspec_metadata! +end + +WebMock.disable_net_connect! RSpec.configure do |config| config.expect_with :rspec do |expectations| diff --git a/wsaa-ruby.gemspec b/wsaa-ruby.gemspec index 989dfb9..4e39efa 100644 --- a/wsaa-ruby.gemspec +++ b/wsaa-ruby.gemspec @@ -28,4 +28,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'bundler', '~> 2.0' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'vcr', '~> 6.0' + spec.add_development_dependency 'webmock', '~> 3.0' end