Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions lib/wsaa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions lib/wsaa/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand All @@ -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!

Expand Down
19 changes: 19 additions & 0 deletions lib/wsaa/cms_signer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions lib/wsaa/configuration.rb
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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?
Expand Down
27 changes: 27 additions & 0 deletions lib/wsaa/credential_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions lib/wsaa/credentials.rb
Original file line number Diff line number Diff line change
@@ -1,22 +1,48 @@
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
@expiration_time = 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,
Expand Down
17 changes: 17 additions & 0 deletions lib/wsaa/errors.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 20 additions & 0 deletions lib/wsaa/tra.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
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
@generation_time = generation_time || default_generation_time
@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')
Expand Down
4 changes: 3 additions & 1 deletion lib/wsaa/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
module Wsaa
VERSION = '0.1.0'
##
# Current version of the wsaa-ruby gem.
VERSION = '0.2.0'
end
Loading
Loading