diff --git a/Gemfile.lock b/Gemfile.lock index d0ea65c..06bb91d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,7 +85,7 @@ GEM nokogiri (1.11.1) mini_portile2 (~> 2.5.0) racc (~> 1.4) - omniauth (2.0.1) + omniauth (2.0.2) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) rack-protection diff --git a/lib/osso.rb b/lib/osso.rb index f2557a0..d67c7f7 100644 --- a/lib/osso.rb +++ b/lib/osso.rb @@ -7,6 +7,8 @@ module Osso require_relative 'osso/lib/oauth2_token' require_relative 'osso/lib/route_map' require_relative 'osso/lib/saml_handler' + require_relative 'osso/lib/scim_query_parser' + require_relative 'osso/lib/scim_schemas' require_relative 'osso/models/models' require_relative 'osso/routes/routes' require_relative 'osso/graphql/schema' diff --git a/lib/osso/lib/route_map.rb b/lib/osso/lib/route_map.rb index 0b65442..989bb0d 100644 --- a/lib/osso/lib/route_map.rb +++ b/lib/osso/lib/route_map.rb @@ -9,6 +9,7 @@ def self.included(klass) use Osso::Admin use Osso::Auth use Osso::Oauth + use Osso::Scim end end end diff --git a/lib/osso/lib/scim_query_parser.rb b/lib/osso/lib/scim_query_parser.rb new file mode 100644 index 0000000..db2b727 --- /dev/null +++ b/lib/osso/lib/scim_query_parser.rb @@ -0,0 +1,67 @@ +module Osso + class ScimQueryParser + # TODO: cribbed this from an open source rails SCIM gem. + # stylistically not quite what I love. + # Anyhow, SCIM decided to go and create their own query language 🤷‍♂️ + # So we need to take the filter param and convert it into something we + # can use to query the DB. Maybe we should be explicit about mapping + # attributes and values to avoid SQL injection but like, if Okta + # is sending authneticated requests with SQL injection I think we + # have bigger problems?? + attr_accessor :query_elements + + def self.perform(qs) + new(qs).raw_sql + end + + def initialize(query_string) + self.query_elements = query_string.split(" ") + end + + def raw_sql + "#{attribute} #{comparator} #{parameter}" + end + + def attribute + attribute = query_elements.dig(0) + raise StandardError if attribute.blank? + attribute = attribute.to_sym + + mapped_attribute = attribute_mapping(attribute) + raise StandardError if mapped_attribute.blank? + ActiveRecord::Base.connection.quote(mapped_attribute) + end + + def comparator + sql_comparator(query_elements.dig(1)) + end + + def parameter + parameter = query_elements[2..-1].join(" ") + return if parameter.blank? + ActiveRecord::Base.connection.quote(parameter.gsub(/"/, "")) + end + + private + + def attribute_mapping(attribute) + { + userName: :email + }[attribute] + end + + def sql_comparator(element) + case element + when "eq" + "=" + when "sw" + "ILIKE %" + else + # TODO: implement additional query filters + # and also this is not the right error class but + # whatever for now + raise NotImplementedError + end + end + end +end \ No newline at end of file diff --git a/lib/osso/lib/scim_schemas.rb b/lib/osso/lib/scim_schemas.rb new file mode 100644 index 0000000..8d31cc1 --- /dev/null +++ b/lib/osso/lib/scim_schemas.rb @@ -0,0 +1,15 @@ +module Osso + module ScimSchema + SCHEMA_URI = 'urn:scim:osso:default:schema' + def user_schema + { + schemas: ["urn:scim:schemas:core:1.0", SCHEMA_URI], + userName: "{$user.email}", + SCHEMA_URI: { + idp_id: "{$user.id}", + email: "{$user.email}" + } + }.to_json + end + end +end \ No newline at end of file diff --git a/lib/osso/models/identity_provider.rb b/lib/osso/models/identity_provider.rb index 582a099..5f57f27 100644 --- a/lib/osso/models/identity_provider.rb +++ b/lib/osso/models/identity_provider.rb @@ -50,6 +50,16 @@ def acs_url_validator Regexp.escape(acs_url) end + def bearer_token + payload = { + id: id, + domain: domain, + } + + token = JWT.encode(payload, ENV['SESSION_SECRET'], 'HS256') + Base64.urlsafe_encode64(token) + end + def set_status self.status = 'configured' if sso_url && sso_cert && pending? end diff --git a/lib/osso/models/user.rb b/lib/osso/models/user.rb index a8d3716..c4e86a8 100644 --- a/lib/osso/models/user.rb +++ b/lib/osso/models/user.rb @@ -19,6 +19,22 @@ def as_json(*) idp: identity_provider.name, } end + + def as_scim_json(*) + { + id: id, + email: email, + name: { + familyName: 'familyName', + givenName: 'givenName', + }, + userName: email, + active: true, + emails: [ + { value: email, primary: true} + ] + } + end end end end diff --git a/lib/osso/routes/routes.rb b/lib/osso/routes/routes.rb index c832a39..695c794 100755 --- a/lib/osso/routes/routes.rb +++ b/lib/osso/routes/routes.rb @@ -8,3 +8,4 @@ require_relative 'admin' require_relative 'auth' require_relative 'oauth' +require_relative 'scim' diff --git a/lib/osso/routes/scim.rb b/lib/osso/routes/scim.rb new file mode 100644 index 0000000..f28167a --- /dev/null +++ b/lib/osso/routes/scim.rb @@ -0,0 +1,133 @@ +module Osso + class Scim < Sinatra::Base + include AppConfig + register Sinatra::Namespace + + namespace '/scim/v2' do + before do + error 401 unless authorized? + end + + get '/Users' do + users = Models::User + users = users.where(identity_provider: current_identity_provider) + users = users.where(ScimQueryParser.perform(params['filter'])) if params['filter'] + + json list_scim_response(count: users.count, resources: users.first(10)) + end + + post '/Users' do + # OneLogin takes our custom schema and passes email and idp_id + # + # user = Models::User.create( + # params[ScimSchema::SCHEMA_URI].merge( + # identity_provider: current_identity_provider + # ) + # ) + + # Okta does not support providing a custom schema template, but + # we can instruct Okta users to map attributes + user = Models::User.create( + email: params[:userName], + idp_id: params[:userName], + identity_provider: current_identity_provider, + ) + + # send webhook to app + + status 201 + json user_scim_response(user) + rescue ActiveRecord::RecordNotUnique + status 409 + end + + get '/Users/:id' do + user = Models::User.find(params[:id]) + + json user_scim_response(user) + rescue + status 404 + + json ({ + detail: "No user for ID", + schemas: [ + 'urn:ietf:params:scim:api:messages:2.0:Error' # Okta + ] + }) + end + + put '/Users/:id' do + + end + + patch '/Users/:id' do + + end + + get '/Groups' do + + end + + get 'ServiceProviderConfig' do + + end + + get 'ResourceTypes' do + + end + + get 'Schemas' do + + end + + post 'Bulk' do + + end + end + + private + + def list_scim_response(count: 0, resources: []) + # TODO: seems this needs to be paginated, but a bit unclear + { + "totalResults": count, + "itemsPerPage":10, + "startIndex":1, + "schemas":[ + "urn:scim:schemas:core:1.0", + 'urn:ietf:params:scim:api:messages:2.0:ListResponse', # Okta required + ScimSchema::SCHEMA_URI + ], + "Resources": resources.map(&:as_scim_json), + } + end + + def user_scim_response(user) + { + "schemas":[ + "urn:scim:schemas:core:2.0", + ScimSchema::SCHEMA_URI, + "urn:ietf:params:scim:schemas:core:2.0:User" # okta + ], + }.merge(user.as_scim_json) + end + + def authorized? + true if current_identity_provider + end + + def current_identity_provider + return @current_identity_provider if defined?(@current_identity_provider) + + jwt = Base64.urlsafe_decode64(bearer_token) + payload = JWT.decode(jwt, ENV['SESSION_SECRET'], 'HS256')[0] + @current_identity_provider = Models::IdentityProvider.find_by(payload) + end + + def bearer_token + pattern = /^Bearer / + header = request.env["HTTP_AUTHORIZATION"] + header.gsub(pattern, '') if header&.match(pattern) + end + end +end