diff --git a/Gemfile.lock b/Gemfile.lock index 897e5a4..b841a39 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: code0-identities (0.0.0) httparty (~> 0.22) + ruby-saml (~> 1.17.0) GEM remote: https://rubygems.org/ @@ -24,8 +25,14 @@ GEM json (2.7.2) language_server-protocol (3.17.0.3) mini_mime (1.1.5) + mini_portile2 (2.8.8) multi_xml (0.7.1) bigdecimal (~> 3.1) + nokogiri (1.16.7) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-linux) + racc (~> 1.4) parallel (1.25.1) parser (3.3.4.0) ast (~> 2.4.1) @@ -77,6 +84,9 @@ GEM rubocop-rspec_rails (2.28.3) rubocop (~> 1.40) ruby-progressbar (1.13.0) + ruby-saml (1.17.0) + nokogiri (>= 1.13.10) + rexml strscan (3.1.0) unicode-display_width (2.5.0) webmock (3.23.1) diff --git a/README.md b/README.md index 0b26ae4..6acb71b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ OAuth: - Microsoft - Github - Gitlab +- SAML ## Installation diff --git a/code0-identities.gemspec b/code0-identities.gemspec index 560fde5..10661ae 100644 --- a/code0-identities.gemspec +++ b/code0-identities.gemspec @@ -31,12 +31,13 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "httparty", "~> 0.22" - spec.add_development_dependency "webmock", "~> 3.23.1" + spec.add_dependency "ruby-saml", "~> 1.17.0" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "rubocop", "~> 1.21" spec.add_development_dependency "rubocop-rake", "~> 0.6" spec.add_development_dependency "rubocop-rspec", "~> 2.29" # Uncomment to register a new dependency of your gem + spec.add_development_dependency "webmock", "~> 3.23.1" spec.metadata["rubygems_mfa_required"] = "true" end diff --git a/lib/code0/identities.rb b/lib/code0/identities.rb index 1d397cf..5590ebe 100644 --- a/lib/code0/identities.rb +++ b/lib/code0/identities.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "httparty" +require "onelogin/ruby-saml" require_relative "identities/version" require_relative "identities/identity_provider" @@ -10,6 +11,7 @@ require_relative "identities/provider/google" require_relative "identities/provider/discord" require_relative "identities/provider/github" +require_relative "identities/provider/saml" module Code0 module Identities diff --git a/lib/code0/identities/provider/saml.rb b/lib/code0/identities/provider/saml.rb new file mode 100644 index 0000000..1902e3b --- /dev/null +++ b/lib/code0/identities/provider/saml.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Code0 + module Identities + module Provider + class Saml + attr_reader :config_loader + + def initialize(config_loader) + @config_loader = config_loader + end + + def authorization_url + request = OneLogin::RubySaml::Authrequest.new + request.create(create_settings) + + request.instance_variable_get :@login_url + end + + def load_identity(**params) + response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], + { **config[:response_settings], settings: create_settings }) + attributes = response.attributes + + Identity.new(config[:provider_name], + response.name_id, + find_attribute(attributes, config[:attribute_statements][:username]), + find_attribute(attributes, config[:attribute_statements][:email]), + find_attribute(attributes, config[:attribute_statements][:firstname]), + find_attribute(attributes, config[:attribute_statements][:lastname])) + end + + private + + def find_attribute(attributes, attribute_statements) + attribute_statements.each do |statement| + return attributes[statement] unless attributes[statement].nil? + end + nil + end + + def create_settings + if config[:metadata_url].nil? + settings = OneLogin::RubySaml::Settings.new + else + idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new + settings = idp_metadata_parser.parse_remote(config[:metadata_url]) + end + + settings.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + + config[:settings].each do |key, value| + settings.send(:"#{key}=", value) + end + settings + end + + def config + config = config_loader + config = config_loader.call if config_loader.is_a?(Proc) + + # rubocop:disable Layout/LineLength + config[:provider_name] ||= :saml + config[:response_settings] ||= {} + config[:settings] ||= {} + config[:attribute_statements] ||= {} + config[:attribute_statements][:username] ||= %w[username name http://schemas.goauthentik.io/2021/02/saml/username] + config[:attribute_statements][:email] ||= %w[email mail http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress http://schemas.microsoft.com/ws/2008/06/identity/claims/emailaddress] + config[:attribute_statements][:firstname] ||= %w[first_name firstname firstName http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname http://schemas.microsoft.com/ws/2008/06/identity/claims/givenname] + config[:attribute_statements][:lastname] ||= %w[last_name lastname lastName http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname http://schemas.microsoft.com/ws/2008/06/identity/claims/surname] + # rubocop:enable Layout/LineLength + + config + end + end + end + end +end diff --git a/sig/code0/identities/provider/saml.rbs b/sig/code0/identities/provider/saml.rbs new file mode 100644 index 0000000..7862e13 --- /dev/null +++ b/sig/code0/identities/provider/saml.rbs @@ -0,0 +1,11 @@ +module Code0 + module Identities + module Provider + class Saml + def authorization_url: () -> String + + def load_identity: (Hash[Symbol, any]) -> Identity + end + end + end +end