From bfbd7f83a3939bef5c8934a0b2dcc756e96b342d Mon Sep 17 00:00:00 2001 From: Juan Pablo Gil Date: Wed, 26 Oct 2022 04:47:08 +0000 Subject: [PATCH] Add idenity plataform model --- .../models/identity_plataform/cert_store.rb | 56 +++++++++ .../models/identity_plataform/error.rb | 6 + .../models/identity_plataform/token.rb | 116 ++++++++++++++++++ .../identity_plataform/warden_strategy.rb | 45 +++++++ 4 files changed, 223 insertions(+) create mode 100644 lib/generators/auth/templates/models/identity_plataform/cert_store.rb create mode 100644 lib/generators/auth/templates/models/identity_plataform/error.rb create mode 100644 lib/generators/auth/templates/models/identity_plataform/token.rb create mode 100644 lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb diff --git a/lib/generators/auth/templates/models/identity_plataform/cert_store.rb b/lib/generators/auth/templates/models/identity_plataform/cert_store.rb new file mode 100644 index 0000000..7f47dbb --- /dev/null +++ b/lib/generators/auth/templates/models/identity_plataform/cert_store.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'net/http' + +module IdentityPlatform + #= DecodeIdentityToken::CertStore + # + # This class is used by the DecodeIdentityToken service to retrieve and store + # the certificates used to properly decode tokens issued by Google Cloud + # Identity Platform + class CertStore + extend MonitorMixin + + CERTS_PATH = '/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com' + CERTS_EXPIRY = 3600 + + mattr_reader :certs_last_refresh + + def self.client + @client ||= Faraday.new('https://www.googleapis.com') do |f| + f.response :json # decode response bodies as JSON + f.adapter :net_http + end + end + + def self.certs_cache_expired? + return true unless certs_last_refresh + + Time.current > certs_last_refresh + CERTS_EXPIRY + end + + def self.certs + refresh_certs if certs_cache_expired? + @@certs + end + + def self.fetch_certs + client.get(CERTS_PATH).tap do |response| + raise Error, 'Failed to fetch certs' unless response.success? + end + end + + def self.refresh_certs + synchronize do + return unless (res = fetch_certs) + + new_certs = res.body.transform_values do |cert_string| + OpenSSL::X509::Certificate.new(cert_string) + end + + (@@certs ||= {}).merge! new_certs + @@certs_last_refresh = Time.current + end + end + end +end diff --git a/lib/generators/auth/templates/models/identity_plataform/error.rb b/lib/generators/auth/templates/models/identity_plataform/error.rb new file mode 100644 index 0000000..7435508 --- /dev/null +++ b/lib/generators/auth/templates/models/identity_plataform/error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module IdentityPlatform + #= IdentityPlatform::Error + class Error < StandardError; end +end diff --git a/lib/generators/auth/templates/models/identity_plataform/token.rb b/lib/generators/auth/templates/models/identity_plataform/token.rb new file mode 100644 index 0000000..49b1940 --- /dev/null +++ b/lib/generators/auth/templates/models/identity_plataform/token.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module IdentityPlatform + #= IdentityPlatform::Token + # + # The tokens we obtain when authenticating users through Google Cloud Identity + # Platform + class Token + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations::Callbacks + + ISSUER_PREFIX = 'https://securetoken.google.com/' + + PAYLOAD_KEY_MAP = { + 'iss' => 'issuer', + 'sub' => 'subject', + 'aud' => 'audience', + 'iat' => 'issued_at', + 'exp' => 'expires_at', + 'auth_time' => 'authenticated_at' + }.freeze + + PAYLOAD_MAPPER = proc { |key| PAYLOAD_KEY_MAP.fetch key, key } + + # Transient attributes: + attr_accessor :token, :payload, :header + + attribute :issuer, type: :string + attribute :subject, type: :string + attribute :audience, type: :string + attribute :issued_at, type: :datetime + attribute :expires_at, type: :datetime + attribute :authenticated_at, type: :datetime + attribute :created_at, type: :datetime + + before_validation :extract_token_payload + + def self.load(given_token) + new(token: given_token) + end + + def self.decode_token_with_cert(token, key, cert) + public_key = cert.public_key + + JWT.decode( + token, + public_key, + !public_key.nil?, + decoding_options.merge(kid: key) + ) + end + + def self.expected_audience + ENV.fetch 'GOOGLE_CLOUD_PROJECT', 'fir-rails-f5432' + end + + def self.expected_issuer + "#{ISSUER_PREFIX}#{expected_audience}" + end + + def self.decoding_options + { + algorithm: 'RS256', + iss: expected_issuer, + aud: expected_audience, + verify_aud: true, + verify_iss: true + } + end + + delegate :certs, to: CertStore + delegate :decode_token_with_cert, to: :class + + private + + def extract_token_payload + decode_token_with_certs + return errors.add(:token, 'invalid token') if payload.blank? + + assign_attributes string_attributes_from_payload + assign_attributes timestamp_attributes_from_payload + end + + def string_attributes_from_payload + payload.slice(*%w[iss sub aud]).transform_keys(&PAYLOAD_MAPPER) + end + + def timestamp_attributes_from_payload + payload + .slice(*%w[iat exp auth_time]) + .transform_keys(&PAYLOAD_MAPPER) + .transform_values { |value| Time.at(value) } + end + + def decode_token_with_certs + certs.detect do |key, cert| + assign_payload_and_header_with_key_and_cert(key, cert) + break if payload.present? || errors.any? + end + end + + def assign_payload_and_header_with_key_and_cert(key, cert) + return if payload.present? + + @payload, @header = decode_token_with_cert(token, key, cert) + @payload = @payload&.with_indifferent_access + rescue JWT::ExpiredSignature + errors.add :token, 'signature expired' + rescue JWT::InvalidIssuerError + errors.add :token, 'invalid issuer' + rescue JWT::DecodeError + nil + end + end +end diff --git a/lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb b/lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb new file mode 100644 index 0000000..060acae --- /dev/null +++ b/lib/generators/auth/templates/models/identity_plataform/warden_strategy.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module IdentityPlatform + #= IdentityPlatform::WardenStrategy + # + # A warden strategy to authenticate users with a token from Identity Platform + class WardenStrategy < Warden::Strategies::Base + def valid? + !token_string.nil? + end + + def authenticate! + fail! 'invalid_token' and return unless token&.valid? + + success! User.from_identity_token(token) + end + + def store? + false + end + + private + + def token + @token ||= IdentityPlatform::Token.load(token_string) if valid? + end + + def token_string + token_string_from_header || token_string_from_request_params + end + + def token_string_from_header + Rack::Auth::AbstractRequest::AUTHORIZATION_KEYS.each do |key| + if env.key?(key) && (token_string = env[key][/^Bearer (.*)/, 1]) + return token_string + end + end + nil + end + + def token_string_from_request_params + params['access_token'] + end + end +end