Skip to content

Commit

Permalink
feat: add authentication (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthv authored Sep 27, 2023
1 parent 03ea7cc commit ce369fd
Show file tree
Hide file tree
Showing 38 changed files with 1,048 additions and 16 deletions.
24 changes: 24 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ Lint/EmptyFile:
Exclude:
- 'packages/forest_admin_rails/app/models/forest_admin_rails/application_record.rb'

Metrics/AbcSize:
Exclude:
- 'packages/forest_admin_agent/lib/forest_admin_agent/auth/auth_manager.rb'
- 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oauth2/forest_provider.rb'
- 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oidc_client_manager.rb'

Metrics/CyclomaticComplexity:
Exclude:
- 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oauth2/forest_provider.rb'

Metrics/MethodLength:
Max: 20
Exclude:
- 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oauth2/forest_provider.rb'

Style/BlockComments:
Exclude:
- 'packages/forest_admin_agent/spec/spec_helper.rb'
Expand Down Expand Up @@ -99,3 +114,12 @@ Style/RedundantConstantBase:

Layout/LineLength:
Max: 120

RSpec/ExampleLength:
Max: 20

RSpec/MultipleExpectations:
Max: 5

RSpec/MultipleMemoizedHelpers:
Max: 10
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Forest Admin agent PHP
# Forest Admin agent Ruby


Forest Admin provides an off-the-shelf administration panel based on a highly-extensible API plugged into your application.
Expand Down
8 changes: 4 additions & 4 deletions bin/run_rspec
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ require 'rspec'
require 'rspec/core/rake_task'

# Liste des dossiers à parcourir pour les tests
folders_to_test = %w[./packages/forestadmin_agent ./packages/forestadmin_rails]
folders_to_test = %w[./packages/forest_admin_agent ./packages/forest_admin_rails]

# Boucle à travers les dossiers et exécute les tests RSpec avec la configuration spécifique
folders_to_test.each do |folder|
if File.directory?(folder)

if File.exist?(folder)
puts "Exécution des tests RSpec dans le dossier : #{folder}"
puts "Running RSpec tests in the folder : #{folder}"
RSpec::Core::Runner.run(%W[--require #{folder}/spec/spec_helper.rb #{folder}])
else
puts "Fichier de configuration RSpec non trouvé dans le dossier : #{folder}/spec"
puts "RSpec configuration file not found in folder : #{folder}/spec"
end
else
puts "Dossier non trouvé : #{folder}"
puts "Folder not found : #{folder}"
end
end
3 changes: 3 additions & 0 deletions packages/forest_admin_agent/forest_admin_agent.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ admin work on any Ruby application."

spec.add_dependency "dry-container", "~> 0.11"
spec.add_dependency "lightly", "~> 0.4.0"
spec.add_dependency "jwt", "~> 2.7"
spec.add_dependency "mono_logger", "~> 1.1"
spec.add_dependency "openid_connect", "~> 2.2"
spec.add_dependency "rake", "~> 13.0"
spec.add_dependency "rack-cors", "~> 2.0"
spec.add_dependency "zeitwerk", "~> 2.3"
end
1 change: 1 addition & 0 deletions packages/forest_admin_agent/lib/forest_admin_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
require 'zeitwerk'

loader = Zeitwerk::Loader.for_gem
loader.inflector.inflect('oauth2' => 'OAuth2')
loader.setup

module ForestAdminAgent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require 'json'

module ForestAdminAgent
module Auth
class AuthManager
def initialize
@oidc = ForestAdminAgent::Auth::OidcClientManager.new
end

def start(rendering_id)
client = @oidc.make_forest_provider rendering_id
client.authorization_uri({ state: JSON.generate({ renderingId: rendering_id }) })
end

def verify_code_and_generate_token(params)
raise Error, ForestAdminAgent::Utils::ErrorMessages::INVALID_STATE_MISSING unless params['state']

if Facades::Container.cache(:debug)
OpenIDConnect.http_config do |options|
options.ssl.verify = false
end
end

rendering_id = get_rendering_id_from_state(params['state'])

forest_provider = @oidc.make_forest_provider rendering_id
forest_provider.authorization_code = params['code']
access_token = forest_provider.access_token! 'none'
resource_owner = forest_provider.get_resource_owner access_token

resource_owner.make_jwt
end

private

def get_rendering_id_from_state(state)
state = JSON.parse(state.tr("'", '"').gsub('=>', ':'))
raise Error, ForestAdminAgent::Utils::ErrorMessages::INVALID_STATE_RENDERING_ID unless state.key? 'renderingId'

begin
Integer(state['renderingId'])
rescue ArgumentError
raise Error, ForestAdminAgent::Utils::ErrorMessages::INVALID_RENDERING_ID
end

state['renderingId'].to_i
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
require 'openid_connect'
require_relative 'forest_resource_owner'

module ForestAdminAgent
module Auth
module OAuth2
class ForestProvider < OpenIDConnect::Client
attr_reader :rendering_id

def initialize(rendering_id, attributes = {})
super attributes
@rendering_id = rendering_id
@authorization_endpoint = '/oidc/auth'
@token_endpoint = '/oidc/token'
self.userinfo_endpoint = "/liana/v2/renderings/#{rendering_id}/authorization"
end

def get_resource_owner(access_token)
headers = { 'forest-token': access_token.access_token, 'forest-secret-key': secret }
hash = check_response do
OpenIDConnect.http_client.get access_token.client.userinfo_uri, {}, headers
end

response = OpenIDConnect::ResponseObject::UserInfo.new hash

create_resource_owner response.raw_attributes[:data]
end

private

def create_resource_owner(data)
ForestResourceOwner.new data, rendering_id
end

def check_response
response = yield
case response.status
when 200
server_error = response.body.key?('errors') ? response.body['errors'][0] : nil
if server_error &&
server_error['name'] == Utils::ErrorMessages::TWO_FACTOR_AUTHENTICATION_REQUIRED
raise Error, Utils::ErrorMessages::TWO_FACTOR_AUTHENTICATION_REQUIRED
end

response.body.with_indifferent_access
when 400
raise OpenIDConnect::BadRequest.new('API Access Failed', response)
when 401
raise OpenIDConnect::Unauthorized.new(Utils::ErrorMessages::AUTHORIZATION_FAILED, response)
when 404
raise OpenIDConnect::HttpError.new(response.status, Utils::ErrorMessages::SECRET_NOT_FOUND, response)
when 422
raise OpenIDConnect::HttpError.new(response.status,
Utils::ErrorMessages::SECRET_AND_RENDERINGID_INCONSISTENT, response)
else
raise OpenIDConnect::HttpError.new(response.status, 'Unknown HttpError', response)
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
require 'date'
require 'jwt'

module ForestAdminAgent
module Auth
module OAuth2
class ForestResourceOwner
def initialize(data, rendering_id)
@data = data
@rendering_id = rendering_id
end

def id
@data['id']
end

def expiration_in_seconds
(DateTime.now + (1 / 24.0)).to_time.to_i
end

def make_jwt
attributes = @data['attributes']
user = {
id: id,
email: attributes['email'],
first_name: attributes['first_name'],
last_name: attributes['last_name'],
team: attributes['teams'][0],
tags: attributes['tags'],
rendering_id: @rendering_id,
exp: expiration_in_seconds,
permission_level: attributes['permission_level']
}

JWT.encode user,
Facades::Container.cache(:auth_secret),
'HS256'
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require 'openid_connect'

module ForestAdminAgent
module Auth
module OAuth2
class OidcConfig
def self.discover!(identifier, cache_options = {})
uri = URI.parse(identifier)
Resource.new(uri).discover!(cache_options).tap do |response|
response.expected_issuer = identifier
response.validate!
end
rescue SWD::Exception, OpenIDConnect::ValidationFailed => e
raise OpenIDConnect::Discovery::DiscoveryFailed, e.message
end

class Resource < OpenIDConnect::Discovery::Provider::Config::Resource
def initialize(uri)
super
@host = uri.host
@port = uri.port unless [80, 443].include?(uri.port)
@path = File.join uri.path, 'oidc/.well-known/openid-configuration'
attr_missing!
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
require 'openid_connect'
require_relative 'oauth2/oidc_config'
require_relative 'oauth2/forest_provider'

module ForestAdminAgent
module Auth
class OidcClientManager
TTL = 60 * 60 * 24

def make_forest_provider(rendering_id)
config_agent = Facades::Container.config_from_cache
cache_key = "#{config_agent[:env_secret]}-client-data"
cache = setup_cache(cache_key, config_agent)

render_provider(cache, rendering_id, config_agent[:env_secret])
end

private

def setup_cache(env_secret, config_agent)
lightly = Lightly.new(life: TTL, dir: "#{config_agent[:cache_dir]}/issuer")
lightly.get env_secret do
oidc_config = retrieve_config(config_agent[:forest_server_url])
credentials = register(
config_agent[:env_secret],
oidc_config.raw['registration_endpoint'],
{
token_endpoint_auth_method: 'none',
registration_endpoint: oidc_config.raw['registration_endpoint'],
application_type: 'web'
}
)

{
client_id: credentials['client_id'],
issuer: oidc_config.raw['issuer'],
redirect_uri: credentials['redirect_uris'].first
}
end
end

def register(env_secret, registration_endpoint, data)
response = OpenIDConnect.http_client.post(
registration_endpoint,
data,
{ 'Authorization' => "Bearer #{env_secret}" }
)

response.body
end

def render_provider(cache, rendering_id, secret)
OAuth2::ForestProvider.new(
rendering_id,
{
identifier: cache[:client_id],
redirect_uri: cache[:redirect_uri],
host: cache[:issuer].to_s.sub(%r{^https?://(www.)?}, ''),
secret: secret
}
)
end

def retrieve_config(uri)
OAuth2::OidcConfig.discover! uri
rescue OpenIDConnect::Discovery::DiscoveryFailed
raise Error, ForestAdminAgent::Utils::ErrorMessages::SERVER_DOWN
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module ForestAdminAgent
module Facades
class Container
def self.instance
ForestAdminAgent::Builder::AgentFactory.instance.container
end

def self.config_from_cache
instance.resolve(:cache).get('config')
end

def self.cache(key)
raise "Key #{key} not found in container" unless config_from_cache.key?(key)

config_from_cache[key]
end
end
end
end
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
module ForestAdminAgent
module Http
class Router
include ForestAdminAgent::Routes

def self.routes
[
# actions_routes,
# api_charts_routes,
ForestAdminAgent::Routes::System::HealthCheck.new.routes
System::HealthCheck.new.routes,
Security::Authentication.new.routes,
Resources::List.new.routes
].inject(&:merge)
end

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
module ForestAdminAgent
module Routes
class AbstractRoute
attr_reader :request

def initialize
@request = ActionDispatch::Request.new({})
@routes = {}
setup_routes
end
Expand Down
Loading

0 comments on commit ce369fd

Please sign in to comment.