# frozen_string_literal: true # enfonica_credentials.rb # # Standalone service-account authentication for the Enfonica API. # # Implements the OAuth2 JWT Bearer Grant flow (RFC 7523): signs a short-lived # JWT with your service account's RSA private key and exchanges it for a # bearer access token at the Enfonica token server. # # Dependencies: the `jwt` gem (https://rubygems.org/gems/jwt) — everything # else is Ruby standard library. # # gem install jwt # # Usage: # # require_relative "enfonica_credentials" # # # From an explicit key file path: # credentials = Enfonica::Credentials.from_file("/path/to/keyfile.json") # # # Or from the ENFONICA_APPLICATION_CREDENTIALS environment variable: # credentials = Enfonica::Credentials.from_env # # # Then, on every API request: # request["Authorization"] = credentials.authorization_header # # Token lifetime / caching: # - The token server issues tokens valid for 60 minutes. # - Tokens are cached in memory and refreshed proactively after 30 minutes. # - No token state is written to disk. # - 401 responses from the API are hard errors, NOT refresh triggers. # - Instances are thread-safe; share one instance across your application. require "json" require "net/http" require "openssl" require "uri" require "jwt" module Enfonica # Raised when a key file is missing or invalid, or a token request fails. class CredentialsError < StandardError; end # Loads an Enfonica service account key and exchanges it for bearer access # tokens, caching them in memory. # # Key file format: # # { # "type": "service_account", # "project_id": "my-project", # "private_key_id": "cf53e0bde8cd6603a9860380a16be3e2985c3701", # "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----\n", # "client_email": "my-sa@my-project.iam.enfonica.com", # "token_uri": "https://oauth2.api.enfonica.com/token" # } class Credentials # The fixed OAuth2 scope used for all Enfonica APIs. SCOPE = "https://api.enfonica.com/auth/platform" GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" # Lifetime of the signed JWT assertion. JWT_LIFETIME_SECONDS = 3600 # Refresh the cached token once it is this old. The server issues tokens # valid for 60 minutes; 30 minutes provides a comfortable safety margin. TOKEN_REFRESH_SECONDS = 30 * 60 REQUIRED_FIELDS = %w[ type project_id private_key_id private_key client_email token_uri ].freeze attr_reader :project_id, :client_email, :token_uri # Load credentials from the path named by the # ENFONICA_APPLICATION_CREDENTIALS environment variable. def self.from_env path = ENV["ENFONICA_APPLICATION_CREDENTIALS"] if path.nil? || path.empty? raise CredentialsError, "No credentials provided. Either pass a key file path to " \ "Enfonica::Credentials.from_file, pass key data to " \ "Enfonica::Credentials.from_hash, or set the " \ "ENFONICA_APPLICATION_CREDENTIALS environment variable to the " \ "path of a service account key file." end from_file(path) end # Load credentials from a service account key file on disk. def self.from_file(path) json = begin File.read(path) rescue SystemCallError => e raise CredentialsError, "Could not read key file '#{path}': #{e.message}" end from_json(json) end # Load credentials from a JSON string. def self.from_json(json) data = begin JSON.parse(json) rescue JSON::ParserError => e raise CredentialsError, "Invalid key file: not valid JSON: #{e.message}" end from_hash(data) end # Load credentials from an already-parsed Hash with string keys. def self.from_hash(data) new(data) end def initialize(data) unless data.is_a?(Hash) raise CredentialsError, "Invalid key file: expected a Hash, got #{data.class}" end unless data["type"] == "service_account" raise CredentialsError, "Invalid key file: expected type 'service_account', " \ "got '#{data["type"]}'" end missing = REQUIRED_FIELDS.reject { |f| data[f].is_a?(String) && !data[f].empty? } unless missing.empty? raise CredentialsError, "Invalid key file: missing required field(s): #{missing.join(", ")}" end @project_id = data["project_id"] @private_key_id = data["private_key_id"] @client_email = data["client_email"] @token_uri = data["token_uri"] # JSON.parse has already converted the \n escape sequences stored in the # key file into real newlines. The body is re-wrapped at 64 characters # per line before parsing: some key files carry longer base64 lines, # which strict PEM parsers reject even though the key material is valid. begin @private_key = OpenSSL::PKey::RSA.new(normalize_pem(data["private_key"])) rescue OpenSSL::PKey::RSAError => e raise CredentialsError, "Invalid key file: private_key is not a valid RSA PEM: #{e.message}" end @mutex = Mutex.new @token = nil @fetched_at = nil end # Returns a valid bearer access token, fetching or refreshing as needed. def access_token @mutex.synchronize do if @token.nil? || monotonic_now - @fetched_at >= TOKEN_REFRESH_SECONDS @token = fetch_token @fetched_at = monotonic_now end @token end end # Convenience: the value for an HTTP +Authorization+ header. def authorization_header "Bearer #{access_token}" end private # Build and sign the JWT assertion, then exchange it for a bearer token: # # POST # Content-Type: application/x-www-form-urlencoded # grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion= def fetch_token uri = URI(@token_uri) request = Net::HTTP::Post.new(uri) request.set_form_data( "grant_type" => GRANT_TYPE, "assertion" => signed_assertion ) response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http| http.request(request) end unless response.is_a?(Net::HTTPSuccess) raise CredentialsError, "Enfonica token request failed: HTTP #{response.code} " \ "from #{@token_uri}: #{response.body}" end begin JSON.parse(response.body).fetch("access_token") rescue JSON::ParserError, KeyError => e raise CredentialsError, "Enfonica token response was malformed: #{e.message}. " \ "Body: #{response.body.inspect}" end end # JWT payload per RFC 7523 §3, signed RS256 with the key file's private # key; the +kid+ header carries the key file's private_key_id. def signed_assertion now = Time.now.to_i payload = { iss: @client_email, aud: @token_uri, scope: SCOPE, iat: now, exp: now + JWT_LIFETIME_SECONDS } JWT.encode(payload, @private_key, "RS256", { typ: "JWT", kid: @private_key_id }) end def monotonic_now Process.clock_gettime(Process::CLOCK_MONOTONIC) end # Rebuild the PEM with its base64 body wrapped at 64 characters per line, # as RFC 7468 specifies. Tolerates input wrapped at other widths, on a # single line, with CRLF endings, or with stray whitespace. def normalize_pem(pem) match = pem.match(/(-----BEGIN [A-Z ]+-----)(.*?)(-----END [A-Z ]+-----)/m) return pem unless match header, body, footer = match.captures "#{header}\n#{body.gsub(/\s+/, "").scan(/.{1,64}/).join("\n")}\n#{footer}\n" end end end