fschuindt/firebase_id_token

View on GitHub
lib/firebase_id_token/certificates.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
module FirebaseIdToken
  # Manage download and access of Google's x509 certificates. Keeps
  # certificates on a Redis namespace database.
  #
  # ## Download & Access Certificates
  #
  # It describes two ways to download it: {.request} and {.request!}.
  # The first will only do something when Redis certificates database is empty,
  # the second one will always request a new download to Google's API and
  # override the database with the response.
  #
  # It's important to note that when saving a set of certificates, it will also
  # set a Redis expiration time to match Google's API header `expires`. **After
  # this time went out, Redis will automatically delete those certificates.**
  #
  # *To know how many seconds left until the expiration you can use {.ttl}.*
  #
  # When comes to accessing it, you can either use {.present?} to check if
  # there's any data inside Redis certificates database or {.all} to obtain an
  # `Array` of current certificates.
  #
  # @example `.request` will only download once
  #   FirebaseIdToken::Certificates.request # Downloads certificates.
  #   FirebaseIdToken::Certificates.request # Won't do anything.
  #   FirebaseIdToken::Certificates.request # Won't do anything either.
  #
  # @example `.request!` will download always
  #   FirebaseIdToken::Certificates.request # Downloads certificates.
  #   FirebaseIdToken::Certificates.request! # Downloads certificates.
  #   FirebaseIdToken::Certificates.request! # Downloads certificates.
  #
  class Certificates
    # A Redis instance.
    attr_reader :redis
    # Certificates saved in the Redis (JSON `String` or `nil`).
    attr_reader :local_certs

    # Google's x509 certificates API URL.
    URL = 'https://www.googleapis.com/robot/v1/metadata/x509/'\
      'securetoken@system.gserviceaccount.com'

    # Calls {.request!} only if there are no certificates on Redis. It will
    # return `nil` otherwise.
    #
    # It will raise {Exceptions::CertificatesRequestError} if the request
    # fails or {Exceptions::CertificatesTtlError} when Google responds with a
    # low TTL, check out {.request!} for more info.
    #
    # @return [nil, Hash]
    # @see Certificates.request!
    def self.request
      new.request
    end

    # Triggers a HTTPS request to Google's x509 certificates API. If it
    # responds with a status `200 OK`, saves the request body into Redis and
    # returns it as a `Hash`.
    #
    # Otherwise it will raise a {Exceptions::CertificatesRequestError}.
    #
    # This is really rare to happen, but Google may respond with a low TTL
    # certificate. This is a `SecurityError` and will raise a
    # {Exceptions::CertificatesTtlError}. You are mostly like to never face it.
    # @return [Hash]
    def self.request!
      new.request!
    end

    # @deprecated Use only `request!` in favor of Ruby conventions.
    # It will raise a warning. Kept for compatibility.
    # @see Certificates.request!
    def self.request_anyway
      warn 'WARNING: FirebaseIdToken::Certificates.request_anyway is '\
        'deprecated. Use FirebaseIdToken::Certificates.request! instead.'

      new.request!
    end

    # Returns `true` if there's certificates data on Redis, `false` otherwise.
    # @example
    #   FirebaseIdToken::Certificates.present? #=> false
    #   FirebaseIdToken::Certificates.request
    #   FirebaseIdToken::Certificates.present? #=> true
    def self.present?
      ! new.local_certs.empty?
    end

    # Returns an array of hashes, each hash is a single `{key => value}` pair
    # containing the certificate KID `String` as key and a
    # `OpenSSL::X509::Certificate` object of the respective certificate as
    # value. Returns a empty `Array` when there's no certificates data on
    # Redis.
    # @return [Array]
    # @example
    #   FirebaseIdToken::Certificates.request
    #   certs = FirebaseIdToken::Certificates.all
    #   certs.first #=> {"1d6d01c7[...]" => #<OpenSSL::X509::Certificate[...]}
    def self.all
      new.local_certs.map { |kid, cert|
        { kid => OpenSSL::X509::Certificate.new(cert) } }
    end

    # Returns a `OpenSSL::X509::Certificate` object of the requested Key ID
    # (KID) if there's one. Returns `nil` otherwise.
    #
    # It will raise a {Exceptions::NoCertificatesError} if the Redis
    # certificates database is empty.
    # @param [String] kid Key ID
    # @return [nil, OpenSSL::X509::Certificate]
    # @example
    #   FirebaseIdToken::Certificates.request
    #   cert = FirebaseIdToken::Certificates.find "1d6d01f4w7d54c7[...]"
    #   #=> <OpenSSL::X509::Certificate: subject=#<OpenSSL [...]
    def self.find(kid, raise_error: false)
      certs = new.local_certs
      raise Exceptions::NoCertificatesError if certs.empty?

      return OpenSSL::X509::Certificate.new certs[kid] if certs[kid]

      return unless raise_error

      raise Exceptions::CertificateNotFound,
        "Unable to find a certificate with `#{kid}`."
    end

    # Returns a `OpenSSL::X509::Certificate` object of the requested Key ID
    # (KID) if there's one.
    #
    # @raise {Exceptions::CertificateNotFound} if it cannot be found.
    #
    # @raise {Exceptions::NoCertificatesError} if the Redis certificates
    # database is empty.
    #
    # @param [String] kid Key ID
    # @return [OpenSSL::X509::Certificate]
    # @example
    #   FirebaseIdToken::Certificates.request
    #   cert = FirebaseIdToken::Certificates.find! "1d6d01f4w7d54c7[...]"
    #   #=> <OpenSSL::X509::Certificate: subject=#<OpenSSL [...]
    def self.find!(kid)
      find(kid, raise_error: true)
    end

    # Returns the current certificates TTL (Time-To-Live) in seconds. *Zero
    # meaning no certificates.* It's the same as the certificates expiration
    # time, use it to know when to request again.
    # @return [Fixnum]
    def self.ttl
      ttl = new.redis.ttl('certificates')
      ttl < 0 ? 0 : ttl
    end

    # Sets two instance attributes: `:redis` and `:local_certs`. Those are
    # respectively a Redis instance from {FirebaseIdToken::Configuration} and
    # the certificates in it.
    def initialize
      @redis = Redis::Namespace.new('firebase_id_token',
        redis: FirebaseIdToken.configuration.redis)
      @local_certs = read_certificates
    end

    # @see Certificates.request
    def request
      request! if @local_certs.empty?
    end

    # @see Certificates.request!
    def request!
      @request = HTTParty.get URL
      code = @request.code
      if code == 200
        save_certificates
      else
        raise Exceptions::CertificatesRequestError.new(code)
      end
    end

    private

    def read_certificates
      certs = @redis.get 'certificates'
      certs ? JSON.parse(certs) : {}
    end

    def save_certificates
      @redis.setex 'certificates', ttl, @request.body
      @local_certs = read_certificates
    end

    def ttl
      cache_control = @request.headers['cache-control']
      ttl = cache_control.match(/max-age=([0-9]+)/).captures.first.to_i

      if ttl > 3600
        ttl
      else
        raise Exceptions::CertificatesTtlError
      end
    end
  end
end