From 80fb3ecc5255fde73495ab61e42f3656711e20ae Mon Sep 17 00:00:00 2001 From: moticless Date: Thu, 4 Oct 2018 00:49:00 +0300 Subject: [PATCH] Authn OIDC - Draft #1 (#736) This is draft #1 of a working OIDC authenticator. Plans to refactor its API are in the works. --- .gitignore | 3 + Dockerfile.test | 16 + Gemfile | 3 + Gemfile.lock | 38 ++ app/controllers/authenticate_controller.rb | 63 +- .../authn_oidc/authenticator.rb | 90 +++ .../authn_oidc/get_user_details.rb | 138 +++++ .../authentication/authn_oidc/user_details.rb | 16 + app/domain/authentication/strategy.rb | 87 ++- ci/authn-oidc/keycloak/create_client | 13 + ci/authn-oidc/phantomjs/fetchAuthCode | 20 + ci/authn-oidc/phantomjs/keycloak_login.js | 93 +++ ci/docker-compose.yml | 22 +- ci/test | 53 +- config/routes.rb | 7 +- cucumber.yml | 1 + .../features/authn_oidc.feature | 43 ++ .../step_definitions/authn_common_steps.rb | 7 + .../step_definitions/authn_ldap_steps.rb | 7 - .../step_definitions/authn_oidc_steps.rb | 11 + .../features/support/authenticator_helpers.rb | 57 +- dev/docker-compose.yml | 38 ++ dev/files/authn-ldap/ldap/ldap.ldif | 8 + dev/files/authn-oidc/keycloak/create_client | 13 + .../authn-oidc/keycloak/fetchCertificate | 3 + dev/files/authn-oidc/keycloak/standalone.xml | 578 ++++++++++++++++++ dev/files/authn-oidc/phantomjs/fetchAuthCode | 20 + .../authn-oidc/phantomjs/keycloak_login.js | 93 +++ dev/files/authn-oidc/policy.yml | 56 ++ .../okta-ldap-agent/conf/OktaLDAPAgent.conf | 19 + dev/start | 71 ++- .../domain/authentication/strategy_spec.rb | 56 +- spec/routing/authn_routing_spec.rb | 19 +- tmp.txt | 53 ++ 34 files changed, 1757 insertions(+), 58 deletions(-) create mode 100644 app/domain/authentication/authn_oidc/authenticator.rb create mode 100644 app/domain/authentication/authn_oidc/get_user_details.rb create mode 100644 app/domain/authentication/authn_oidc/user_details.rb create mode 100755 ci/authn-oidc/keycloak/create_client create mode 100755 ci/authn-oidc/phantomjs/fetchAuthCode create mode 100644 ci/authn-oidc/phantomjs/keycloak_login.js create mode 100644 cucumber/authenticators/features/authn_oidc.feature create mode 100644 cucumber/authenticators/features/step_definitions/authn_common_steps.rb create mode 100644 cucumber/authenticators/features/step_definitions/authn_oidc_steps.rb create mode 100755 dev/files/authn-oidc/keycloak/create_client create mode 100755 dev/files/authn-oidc/keycloak/fetchCertificate create mode 100644 dev/files/authn-oidc/keycloak/standalone.xml create mode 100755 dev/files/authn-oidc/phantomjs/fetchAuthCode create mode 100644 dev/files/authn-oidc/phantomjs/keycloak_login.js create mode 100644 dev/files/authn-oidc/policy.yml create mode 100644 dev/files/okta-ldap-agent/conf/OktaLDAPAgent.conf create mode 100644 tmp.txt diff --git a/.gitignore b/.gitignore index 6e891bc1ef..45bc6fec84 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ ci/authn-k8s/dev/*.yaml ci/authn-k8s/dev/policies/*.yml !ci/authn-k8s/dev/policies/*template* +# authn-oidc phantomjs temp files +dev/files/authn-oidc/phantomjs/authorization_code +dev/files/authn-oidc/phantomjs/keycloak_login_tmp.js diff --git a/Dockerfile.test b/Dockerfile.test index a91f36550c..29960b02a7 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -2,3 +2,19 @@ ARG VERSION=latest FROM conjur:${VERSION} RUN bundle --no-deployment --without '' + +RUN echo Installing phantomjs +RUN apt-get update -y && \ + apt-get install -y build-essential chrpath libssl-dev libxft-dev && \ + apt-get install -y libfreetype6 libfreetype6-dev && \ + apt-get install -y libfontconfig1 libfontconfig1-dev wget + +RUN cd ~ +ENV PHANTOM_JS="phantomjs-1.9.8-linux-x86_64" +RUN wget https://bitbucket.org/ariya/phantomjs/downloads/${PHANTOM_JS}.tar.bz2 +RUN tar xvjf $PHANTOM_JS.tar.bz2 + +RUN mv $PHANTOM_JS /usr/local/share +RUN ln -sf /usr/local/share/${PHANTOM_JS}/bin/phantomjs /usr/local/bin + +RUN phantomjs --version diff --git a/Gemfile b/Gemfile index 4250550494..a7e2c93c40 100644 --- a/Gemfile +++ b/Gemfile @@ -69,6 +69,9 @@ end gem 'kubeclient' gem 'websocket-client-simple' +# authn-oidc +gem 'openid_connect' + group :development, :test do gem 'aruba' gem 'csr' diff --git a/Gemfile.lock b/Gemfile.lock index 9da3fae5c1..c67547f165 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -55,6 +55,7 @@ GEM tzinfo (~> 1.1) addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) + aes_key_wrap (1.0.1) arel (6.0.4) aruba (0.14.2) childprocess (~> 0.5.6) @@ -64,6 +65,7 @@ GEM rspec-expectations (>= 2.99) thor (~> 0.19) ast (2.4.0) + attr_required (1.0.1) autoprefixer-rails (7.1.2.3) execjs aws-eventstream (1.0.1) @@ -84,6 +86,7 @@ GEM base32-crockford (0.1.0) base58 (0.2.3) bcrypt-ruby (3.0.1) + bindata (2.4.3) bootstrap-sass (3.2.0.2) sass (~> 3.2) builder (3.2.3) @@ -194,6 +197,7 @@ GEM domain_name (~> 0.5) http-form_data (1.0.3) http_parser.rb (0.6.0) + httpclient (2.8.3) i18n (0.9.5) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -202,6 +206,10 @@ GEM jaro_winkler (1.5.1) jmespath (1.4.0) json (2.1.0) + json-jwt (1.9.4) + activesupport + aes_key_wrap + bindata json_spec (1.1.5) multi_json (~> 1.0) rspec (>= 2.0, < 4.0) @@ -232,6 +240,16 @@ GEM netrc (0.11.0) nokogiri (1.8.4) mini_portile2 (~> 2.3.0) + openid_connect (1.1.6) + activemodel + attr_required (>= 1.0.0) + json-jwt (>= 1.5.0) + rack-oauth2 (>= 1.6.1) + swd (>= 1.0.0) + tzinfo + validate_email + validate_url + webfinger (>= 1.0.1) parallel (1.12.1) parser (2.5.1.2) ast (~> 2.4.0) @@ -249,6 +267,12 @@ GEM public_suffix (3.0.2) puma (3.12.0) rack (1.6.10) + rack-oauth2 (1.9.2) + activesupport + attr_required + httpclient + json-jwt (>= 1.9.0) + rack rack-rewrite (1.5.1) rack-test (0.6.3) rack (>= 1.0) @@ -372,6 +396,10 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) + swd (1.1.2) + activesupport (>= 3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) table_print (1.5.6) therubyracer (0.12.3) libv8 (~> 3.16.14.15) @@ -387,11 +415,20 @@ GEM unf_ext unf_ext (0.0.7.5) unicode-display_width (1.4.0) + validate_email (0.1.6) + activemodel (>= 3.0) + mail (>= 2.2.5) + validate_url (1.0.2) + activemodel (>= 3.0.0) + addressable virtus (1.0.5) axiom-types (~> 0.1) coercible (~> 1.0) descendants_tracker (~> 0.0, >= 0.0.3) equalizer (~> 0.0, >= 0.0.9) + webfinger (1.1.0) + activesupport + httpclient (>= 2.4) websocket (1.2.8) websocket-client-simple (0.3.0) event_emitter @@ -432,6 +469,7 @@ DEPENDENCIES listen net-ldap nokogiri (>= 1.8.2) + openid_connect parallel pg pry-byebug diff --git a/app/controllers/authenticate_controller.rb b/app/controllers/authenticate_controller.rb index f9770848b8..eec2ac7a03 100644 --- a/app/controllers/authenticate_controller.rb +++ b/app/controllers/authenticate_controller.rb @@ -3,11 +3,11 @@ class AuthenticateController < ApplicationController include BasicAuthenticator - def index + def index authenticators = { # Installed authenticator plugins installed: installed_authenticators.keys.sort, - + # Authenticator webservices created in policy configured: configured_authenticators.sort, @@ -25,14 +25,37 @@ def login end def authenticate - authn_token = authentication_strategy.conjur_token(authentication_input) + authn_token = authentication_strategy.conjur_token( + ::Authentication::Strategy::Input.new( + authenticator_name: params[:authenticator], + service_id: params[:service_id], + account: params[:account], + username: params[:id], + password: request.body.read, + origin: request.ip, + request: request + ) + ) render json: authn_token rescue => e - logger.debug("Authentication Error: #{e.message}") - e.backtrace.each do |line| - logger.debug(line) - end - raise Unauthorized + handle_authentication_error(e) + end + + def authenticate_oidc + authentication_token = authentication_strategy.conjur_token_oidc( + ::Authentication::Strategy::Input.new( + authenticator_name: 'authn-oidc', + service_id: params[:service_id], + account: params[:account], + username: nil, + password: nil, # TODO: Remove once we seperate oidc Strategy + origin: request.ip, + request: request + ) + ) + render json: authentication_token + rescue => e + handle_authentication_error(e) end def k8s_inject_client_cert @@ -44,15 +67,19 @@ def k8s_inject_client_cert ) head :ok rescue => e - logger.debug("Authentication Error: #{e.message}") - e.backtrace.each do |line| + handle_authentication_error(e) + end + + private + + def handle_authentication_error(err) + logger.debug("Authentication Error: #{err.message}") + err.backtrace.each do |line| logger.debug(line) end raise Unauthorized end - private - def authentication_strategy @authentication_strategy ||= ::Authentication::Strategy.new( authenticators: installed_authenticators, @@ -64,18 +91,6 @@ def authentication_strategy ) end - def authentication_input - ::Authentication::Strategy::Input.new( - authenticator_name: params[:authenticator], - service_id: params[:service_id], - account: params[:account], - username: params[:id], - password: request.body.read, - origin: request.ip, - request: request - ) - end - def installed_authenticators @installed_authenticators ||= ::Authentication::InstalledAuthenticators.authenticators(ENV) end diff --git a/app/domain/authentication/authn_oidc/authenticator.rb b/app/domain/authentication/authn_oidc/authenticator.rb new file mode 100644 index 0000000000..97343bca7a --- /dev/null +++ b/app/domain/authentication/authn_oidc/authenticator.rb @@ -0,0 +1,90 @@ +require 'command_class' + +module Authentication + module AuthnOidc + class AuthenticationError < RuntimeError; end + class NotFoundError < RuntimeError; end + class OIDCConfigurationError < RuntimeError; end + class OIDCAuthenticationError < RuntimeError; end + + # TODO: Should really have a verb name "Authenticate" since it's a command + # object but we'll leave it like this for now for consistency + # + Authenticator = CommandClass.new( + dependencies: { env: ENV }, + inputs: %i(input user_details) + ) do + + def call + validate_id_token_claims + validate_user_info + true + end + + private + + def validate_id_token_claims + expected = { client_id: client_id, issuer: issuer } # , nonce: 'nonce'} + @user_details.id_token.verify!(expected) + end + + def validate_user_info + raise OIDCAuthenticationError, subject_err_msg unless valid_subject? + raise OIDCAuthenticationError, no_profile_err_msg unless preferred_username + end + + def user_info + @user_details.user_info + end + + def valid_subject? + user_info.sub == id_token_subject + end + + def preferred_username + user_info.preferred_username + end + + def client_id + @user_details.client_id + end + + def issuer + @user_details.issuer + end + + def id_token_subject + @user_details.id_token.sub + end + + def authenticator_name + @input.authenticator_name + end + + def service_id + @input.service_id + end + + def conjur_account + @input.account + end + + def request_body + @request_body ||= @input.request.body.read + end + + def service + @service ||= Resource["#{conjur_account}:webservice:conjur/#{authenticator_name}/#{service_id}"] + end + + def no_profile_err_msg + "[profile] is not included in scope of authorization code request" + end + + def subject_err_msg + "User info subject [#{user_info.sub}] and id token subject " + + "[#{id_token_subject}] are not equal" + end + end + end +end diff --git a/app/domain/authentication/authn_oidc/get_user_details.rb b/app/domain/authentication/authn_oidc/get_user_details.rb new file mode 100644 index 0000000000..511c375af3 --- /dev/null +++ b/app/domain/authentication/authn_oidc/get_user_details.rb @@ -0,0 +1,138 @@ +require 'uri' +require 'openid_connect' +require 'command_class' +require 'conjur/fetch_required_secrets' + +module Authentication + module AuthnOidc + + # TODO: (later version) Fix CommandClass so we can add errors directly + # inside of it + # + # TODO: list any OIDC or other errors here. The errors are part of the + # API. + # + # Errors from FetchRequiredSecrets + # + RequiredResourceMissing = ::Conjur::RequiredResourceMissing + RequiredSecretMissing = ::Conjur::RequiredSecretMissing + + GetUserDetails = CommandClass.new( + dependencies: { fetch_secrets: ::Conjur::FetchRequiredSecrets.new }, + inputs: %i(request_body service_id conjur_account) + ) do + + # @return [AuthOidc::UserDetails] containing decoded id token, user info, + # and issuer + def call + configure_oidc_client + user_details + end + + private + + def configure_oidc_client + oidc_client.authorization_code = authorization_code + oidc_client.host = URI.parse(provider_uri).host + end + + def user_details + UserDetails.new( + id_token: id_token, + user_info: user_info, + client_id: client_id, + issuer: discovered_resource.issuer + ) + end + + # TODO: capture exception: JSON::JWK::Set::KidNotFound and try refresh + # signing keys + def id_token + OpenIDConnect::ResponseObject::IdToken.decode( + access_token.id_token, discovered_resource.jwks + ) + end + + def user_info + access_token.userinfo! + end + + def access_token + @access_token ||= oidc_client.access_token! + end + + def decoded_body + @decoded_body ||= URI.decode_www_form(@request_body) + end + + def redirect_uri + @redirect_uri ||= decoded_body.assoc('redirect_uri').last + end + + def authorization_code + @authorization_code ||= decoded_body.assoc('code').last + end + + def required_secrets + @required_secrets ||= @fetch_secrets.(resource_ids: required_resource_ids) + end + + def required_resource_ids + required_variable_names.map { |var_name| variable_id(var_name) } + end + + def required_variable_names + %w(client-id client-secret provider-uri) + end + + # TODO: for next version: push this logic into a reusable value object + # + # NOTE: technically this should be memoized by argument (through memoist + # gem, eg) but the calc is so simple it doesn't matter. + def variable_id(var_name) + "#{@conjur_account}:variable:conjur/authn-oidc/#{@service_id}/#{var_name}" + end + + def client_id + @client_id ||= secret_value('client-id') + end + + def client_secret + @client_secret ||= secret_value('client-secret') + end + + def provider_uri + @provider_uri ||= secret_value('provider-uri') + end + + def secret_value(var_name) + required_secrets[variable_id(var_name)] + end + + # TODO: disable_ssl_verification should not run in production + def discovered_resource + return @discovered_resource if @discovered_resource + disable_ssl_verification + @discovered_resource ||= OpenIDConnect::Discovery::Provider::Config.discover!(provider_uri) + end + + # TODO: Delete disable ssl action after fix OpenID connect to support + # self sign ceritficate + def disable_ssl_verification + OpenIDConnect.http_config do |config| + config.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + end + + def oidc_client + @oidc_client ||= OpenIDConnect::Client.new( + identifier: client_id, + secret: client_secret, + redirect_uri: redirect_uri, + token_endpoint: discovered_resource.token_endpoint, + userinfo_endpoint: discovered_resource.userinfo_endpoint + ) + end + end + end +end diff --git a/app/domain/authentication/authn_oidc/user_details.rb b/app/domain/authentication/authn_oidc/user_details.rb new file mode 100644 index 0000000000..1eef3fdfb0 --- /dev/null +++ b/app/domain/authentication/authn_oidc/user_details.rb @@ -0,0 +1,16 @@ +module Authentication + module AuthnOidc + class UserDetails + attr_reader :id_token, :user_info, :client_id, :issuer + + def initialize(id_token:, user_info:, client_id:, issuer:) + @id_token = id_token + @user_info = user_info + # TODO: either this class should be renamed, or we figure out a refactor + # that brings this data to the authenticator in a different way. + @client_id = client_id + @issuer = issuer + end + end + end +end diff --git a/app/domain/authentication/strategy.rb b/app/domain/authentication/strategy.rb index efa1516db4..2556ea6145 100644 --- a/app/domain/authentication/strategy.rb +++ b/app/domain/authentication/strategy.rb @@ -20,7 +20,7 @@ class Input < ::Dry::Struct attribute :authenticator_name, ::Types::NonEmptyString attribute :service_id, ::Types::NonEmptyString.optional attribute :account, ::Types::NonEmptyString - attribute :username, ::Types::NonEmptyString + attribute :username, ::Types::NonEmptyString.optional attribute :password, ::Types::String attribute :origin, ::Types::NonEmptyString attribute :request, ::Types::Any.optional # for k8s authenticator @@ -38,6 +38,13 @@ def to_access_request(env) ) end + # Creates a copy of this object with the attributes updated by those + # specified in hash + # + def update(hash) + self.class.new(to_hash.merge(hash)) + end + def webservice @webservice ||= ::Authentication::Webservice.new( account: account, @@ -63,6 +70,22 @@ def self.default_authenticator_name attribute :role_cls, ::Types::Any.default{ ::Role } attribute :audit_log, ::Types::Any.default{ AuditLog } + def login(input) + authenticator = authenticators[input.authenticator_name] + + validate_authenticator_exists(input, authenticator) + validate_security(input) + + key = authenticator.login(input) + raise InvalidCredentials unless key + + audit_success(input) + new_login(input, key) + rescue => err + audit_failure(input, err) + raise err + end + def conjur_token(input) authenticator = authenticators[input.authenticator_name] @@ -79,24 +102,62 @@ def conjur_token(input) raise e end - def login(input) - authenticator = authenticators[input.authenticator_name] - - validate_authenticator_exists(input, authenticator) - validate_security(input) + # TODO: (later version) Extract this and related private methods into its + # own object. We'll need to break down Strategy into its component parts + # to avoid repetition, and then use those parts in both the new + # "OIDCStrategy" and this original Strategy. + # + # Or take a different approach that accomplishes the same goals + # + def conjur_token_oidc(input) + user_details = oidc_user_details(input) + username = user_details.user_info.preferred_username + input_with_username = input.update(username: username) - key = authenticator.login(input) - raise InvalidCredentials unless key + validate_security(input_with_username) + oidc_validate_credentials(input_with_username, user_details) + validate_origin(input_with_username) - audit_success(input) - new_login(input, key) - rescue => err - audit_failure(input, err) - raise err + audit_success(input_with_username) + new_token(input_with_username) + rescue => e + audit_failure(input, e) + raise e end private + # NOTE: These two methods are "special" (outside the framework) by design. + # We already know that the OIDC authenticator doesn't fit within this + # framework design, and will be pulling it out into multiple routes and its + # own objects on the next iteration. + # + # Thus these two methods actually represent the first step in that + # direction. They also more honestly portray the situation. + # + def oidc_user_details(input) + AuthnOidc::GetUserDetails.new.( + request_body: input.request.body.read, + service_id: input.service_id, + conjur_account: input.account + ) + end + + # NOTE: We can revisit this decision, but for now there is absolutely no + # reason to be bound the `valid?(input)` interface for this "exceptional" + # authenticator. + # + # Since we've already get to call `GetUserDetials` here for the username to + # be used in `validate_security`, we don't want to recalculate it, so we + # pass the result in. + # + def oidc_validate_credentials(input_with_username, user_details) + AuthnOidc::Authenticator.new.( + input: input_with_username, + user_details: user_details + ) + end + def audit_success(input) audit_log.record_authn_event( role: role(input.username, input.account), diff --git a/ci/authn-oidc/keycloak/create_client b/ci/authn-oidc/keycloak/create_client new file mode 100755 index 0000000000..7b14ed4561 --- /dev/null +++ b/ci/authn-oidc/keycloak/create_client @@ -0,0 +1,13 @@ +#!/bin/sh + + +keycloak/bin/kcreg.sh config credentials \ + --server http://localhost:8080/auth \ + --realm master \ + --user "$KEYCLOAK_USER" \ + --password "$KEYCLOAK_PASSWORD" +keycloak/bin/kcreg.sh create \ + -s clientId="$CLIENT_ID" \ + -s "redirectUris=[\"$REDIRECT_URI\"]" \ + -s "secret=$CLIENT_SECRET" +keycloak/bin/kcreg.sh get "$CLIENT_ID" | jq '.secret' diff --git a/ci/authn-oidc/phantomjs/fetchAuthCode b/ci/authn-oidc/phantomjs/fetchAuthCode new file mode 100755 index 0000000000..a06e2999eb --- /dev/null +++ b/ci/authn-oidc/phantomjs/fetchAuthCode @@ -0,0 +1,20 @@ +#!/bin/sh + +echo "Fetching authorization code" + +cd /authn-oidc/phantomjs/scripts +echo "Deleting authorization_code file" +rm -rf authorization_code || true + +echo "Copying file keycloak_login.js" +cp -rf keycloak_login.js keycloak_login_tmp.js + +echo "Replacing user and password in keycloak_login_tmp.js" + +sed -i 's/TO_BE_REPLACED_USER/'"$KEYCLOAK_USER"'/g' keycloak_login_tmp.js +sed -i 's/TO_BE_REPLACED_PASSSWORD/'"$KEYCLOAK_PASSWORD"'/g' keycloak_login_tmp.js + +echo "Running phantomjs script" +phantomjs --ignore-ssl-errors=true keycloak_login_tmp.js + +echo "Script complete!" diff --git a/ci/authn-oidc/phantomjs/keycloak_login.js b/ci/authn-oidc/phantomjs/keycloak_login.js new file mode 100644 index 0000000000..accd900c67 --- /dev/null +++ b/ci/authn-oidc/phantomjs/keycloak_login.js @@ -0,0 +1,93 @@ +var steps=[]; +var testindex = 0; +var loadInProgress = false;//This is set to true when a page is still loading + +/*********SETTINGS*********************/ +var webPage = require('webpage'); + +var page = webPage.create(); +var system = require('system'); +var env = system.env; +page.settings.userAgent = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36'; +page.settings.javascriptEnabled = true; +page.settings.loadImages = false;//Script is much faster with this field set to false +phantom.cookiesEnabled = true; +phantom.javascriptEnabled = true; +/*********SETTINGS END*****************/ + +console.log('All settings loaded, start with execution'); +page.onConsoleMessage = function(msg) { + console.log(msg); +}; +/**********DEFINE STEPS THAT FANTOM SHOULD DO***********************/ +steps = [ + + + function(){ + console.log('Open keycloak home page'); + var authorize_request = "https://keycloak:8443/auth/realms/master/protocol/openid-connect/auth"; + authorize_request = authorize_request + "?client_id=" + env['CLIENT_ID']; + authorize_request = authorize_request + "&response_type=code&response_mode=query"; + authorize_request = authorize_request + "&scope=" + env['SCOPE'].replace(/,/g, " "); + authorize_request = authorize_request + "&redirect_uri=" + env['REDIRECT_URI']; + console.log('Rest request : ' + authorize_request.toString()); + //http://keycloak:8080/auth/admin + //example to request: http://keycloak:8080/auth/realms/master/protocol/openid-connect/auth?client_id=myclient&response_type=code&response_mode=query&scope=openid profile&redirect_uri=http://locallhost.com/" + page.open(authorize_request.toString(), function(status){ + console.log('Rest request status: ' + status); + }); + }, + + function(){ + console.log('Populate and submit the login form'); + page.evaluate(function(){ + document.getElementById("username").value = "TO_BE_REPLACED_USER"; + document.getElementById("password").value = "TO_BE_REPLACED_PASSSWORD"; + document.getElementById("kc-login").click(); + }); + }, + + function(){ + console.log("Wait keycloak to login user"); + var fs = require('fs'); + var result = page.evaluate(function() { + return document.querySelectorAll("html")[0].outerHTML; + }); + var code = result.substring(result.indexOf('code=') + 5, result.length); + code = code.substring(0, code.indexOf('>http') - 1); + console.log('authorization code=' + code); + fs.write('authorization_code',code,'w'); + }, +]; +/**********END STEPS THAT FANTOM SHOULD DO***********************/ + +//Execute steps one by one +interval = setInterval(executeRequestsStepByStep,50); + +function executeRequestsStepByStep(){ + if (loadInProgress == false && typeof steps[testindex] == "function") { + //console.log("step " + (testindex + 1)); + steps[testindex](); + testindex++; + } + if (typeof steps[testindex] != "function") { + console.log("test complete!"); + phantom.exit(); + } +} + +/** + * These listeners are very important in order to phantom work properly. Using these listeners, we control loadInProgress marker which controls, weather a page is fully loaded. + * Without this, we will get content of the page, even a page is not fully loaded. + */ +page.onLoadStarted = function() { + loadInProgress = true; + console.log('Loading started'); +}; +page.onLoadFinished = function() { + loadInProgress = false; + console.log('Loading finished'); +}; +page.onConsoleMessage = function(msg) { + console.log(msg); +}; diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index cf3c83d6f1..886ba074d6 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -14,7 +14,7 @@ services: CONJUR_ACCOUNT: cucumber CONJUR_DATA_KEY: RAILS_ENV: - CONJUR_AUTHENTICATORS: authn-ldap/test + CONJUR_AUTHENTICATORS: authn-ldap/test,authn-oidc/keycloak LDAP_URI: ldap://ldap-server:389 LDAP_BASE: dc=conjur,dc=net LDAP_FILTER: '(uid=%s)' @@ -28,6 +28,7 @@ services: links: - pg - ldap-server + - oidc-keycloak:keycloak cucumber: image: conjur-test:$TAG @@ -42,10 +43,12 @@ services: volumes: - ..:/src/conjur-server - authn-local:/run/authn-local + - ./authn-oidc/phantomjs:/authn-oidc/phantomjs/scripts links: - conjur - pg - testdb + - oidc-keycloak:keycloak ldap-server: image: osixia/openldap @@ -54,9 +57,24 @@ services: LDAP_ORGANISATION: CyberArk LDAP_DOMAIN: conjur.net LDAP_ADMIN_PASSWORD: ldapsecret - volumes: - ./authn-ldap/ldap-data:/container/service/slapd/assets/config/bootstrap/ldif/custom + oidc-keycloak: + image: jboss/keycloak + environment: + - KEYCLOAK_USER=alice + - KEYCLOAK_PASSWORD=alice + - DB_VENDOR=H2 + - CLIENT_ID=conjurClient + - REDIRECT_URI=http://locallhost.com/ + - CLIENT_SECRET=d7047915-9029-45b8-9bd6-7ec5c2f75e5b + - SCOPE=openid,profile + ports: + - "7777:8080" + volumes: + - ./authn-oidc/keycloak:/scripts + + volumes: authn-local: diff --git a/ci/test b/ci/test index 8d0d30f4b0..8f0f9902d3 100755 --- a/ci/test +++ b/ci/test @@ -58,12 +58,58 @@ function run_cucumber_tests() { role retrieve-key cucumber:user:admin | tr -d '\r') # Run the tests - docker-compose run --no-deps -T --rm -e CONJUR_AUTHN_API_KEY=$api_key cucumber -c \ - "bundle exec cucumber -p $profile --format junit --out cucumber/$profile/features/reports" + docker-compose run --no-deps -T --rm $cucumber_env_args -e CONJUR_AUTHN_API_KEY=$api_key cucumber -c \ + "bundle exec cucumber -p $profile --format junit --out cucumber/$profile/features/reports" + cucumber_env_args="" docker-compose down --rmi 'local' --volumes } +function prepare_env_auth_oidc() { + docker-compose up --no-deps -d oidc-keycloak + + # Fetch oidc-keycloak environment variables + KEYCLOAK_USER=$(docker-compose exec -T oidc-keycloak printenv KEYCLOAK_USER | tr -d '\r') + KEYCLOAK_PASSWORD=$(docker-compose exec -T oidc-keycloak printenv KEYCLOAK_PASSWORD | tr -d '\r') + CLIENT_ID=$(docker-compose exec -T oidc-keycloak printenv CLIENT_ID | tr -d '\r') + REDIRECT_URI=$(docker-compose exec -T oidc-keycloak printenv REDIRECT_URI | tr -d '\r') + CLIENT_SECRET=$(docker-compose exec -T oidc-keycloak printenv CLIENT_SECRET | tr -d '\r') + SCOPE=$(docker-compose exec -T oidc-keycloak printenv SCOPE | tr -d '\r') + cucumber_env_args="$cucumber_env_args -e KEYCLOAK_USER=$KEYCLOAK_USER" + cucumber_env_args="$cucumber_env_args -e KEYCLOAK_PASSWORD=$KEYCLOAK_PASSWORD" + cucumber_env_args="$cucumber_env_args -e CLIENT_ID=$CLIENT_ID" + cucumber_env_args="$cucumber_env_args -e REDIRECT_URI=$REDIRECT_URI" + cucumber_env_args="$cucumber_env_args -e CLIENT_SECRET=$CLIENT_SECRET" + cucumber_env_args="$cucumber_env_args -e PROVIDER_URI=https://keycloak:8443/auth/realms/master" + cucumber_env_args="$cucumber_env_args -e SCOPE=$SCOPE" + + # Check if keycloak is up + keycloak_isready? + + # Define oidc-keycloak client + docker-compose exec -T oidc-keycloak /scripts/create_client + +} + +function keycloak_isready?() { + for i in {1..10} + do + sleep=10 + echo "keycloak starting logs:" + echo "$(docker-compose logs oidc-keycloak)" + output=$(docker-compose logs oidc-keycloak | grep "started" | wc -l) + if [ $output -ne 0 ]; then + echo "Keycloak server is up and ready" + return 0; + else + echo "Keycloak not ready yet sleep number $i for $sleep seconds" + sleep $sleep + fi + done + echo "Error with keycloak server start or it is too slow" + return 1 +} + # Setup to allow compose to run in an isolated namespace export COMPOSE_PROJECT_NAME="$(openssl rand -hex 3)" @@ -102,7 +148,8 @@ if [[ $RUN_ROTATORS = true || $RUN_ALL = true ]]; then fi if [[ $RUN_AUTHENTICATORS = true || $RUN_ALL = true ]]; then - services="$services ldap-server" + services="$services oidc-keycloak ldap-server" + prepare_env_auth_oidc run_cucumber_tests 'authenticators' fi diff --git a/config/routes.rb b/config/routes.rb index 7c347bcbdb..ce4a619cdc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,7 @@ def matches?(request) constraints account: /[^\/\?]+/ do constraints authenticator: /authn-?[^\/]*/, id: /[^\/\?]+/ do get '/:authenticator(/:service_id)/:account/login' => 'authenticate#login' + post '/authn-oidc(/:service_id)/:account/authenticate' => 'authenticate#authenticate_oidc' post '/:authenticator(/:service_id)/:account/:id/authenticate' => 'authenticate#authenticate' # Update password is only relevant when using the default authenticator @@ -31,7 +32,7 @@ def matches?(request) # other authenticators will return this at login (e.g. LDAP), we want # this to be accessible when using other authenticators to login. put '/:authenticator/:account/api_key' => 'credentials#rotate_api_key' - + post '/authn-k8s/:service_id/inject_client_cert' => 'authenticate#k8s_inject_client_cert' end @@ -42,7 +43,7 @@ def matches?(request) post "/roles/:account/:kind/*identifier" => "roles#add_member", :constraints => QueryParameterActionRecognizer.new("members") delete "/roles/:account/:kind/*identifier" => "roles#delete_member", :constraints => QueryParameterActionRecognizer.new("members") get "/roles/:account/:kind/*identifier" => "roles#show" - + get "/resources/:account/:kind/*identifier" => 'resources#check_permission', :constraints => QueryParameterActionRecognizer.new("check") get "/resources/:account/:kind/*identifier" => 'resources#permitted_roles', :constraints => QueryParameterActionRecognizer.new("permitted_roles") @@ -51,7 +52,7 @@ def matches?(request) get "/resources/:account" => "resources#index" get "/resources" => "resources#index" - # NOTE: the order of these routes matters: we need the expire + # NOTE: the order of these routes matters: we need the expire # route to come first. post "/secrets/:account/:kind/*identifier" => "secrets#expire", :constraints => QueryParameterActionRecognizer.new("expirations") diff --git a/cucumber.yml b/cucumber.yml index f3afe9cf6f..56d44b661c 100644 --- a/cucumber.yml +++ b/cucumber.yml @@ -12,6 +12,7 @@ authenticators: > --format pretty -r cucumber/api/features/support/rest_helpers.rb -r cucumber/api/features/step_definitions/request_steps.rb + -r cucumber/api/features/step_definitions/user_steps.rb -r cucumber/authenticators cucumber/authenticators diff --git a/cucumber/authenticators/features/authn_oidc.feature b/cucumber/authenticators/features/authn_oidc.feature new file mode 100644 index 0000000000..daa46afa9f --- /dev/null +++ b/cucumber/authenticators/features/authn_oidc.feature @@ -0,0 +1,43 @@ +Feature: Users can authneticate with OIDC authenticator + + Background: + Given a policy: + """ + - !user alice + + - !policy + id: conjur/authn-oidc/keycloak + body: + - !webservice + annotations: + description: Authentication service for Keycloak, based on Open ID Connect. + + - !variable + id: client-id + + - !variable + id: client-secret + + - !variable + id: provider-uri + + - !group users + + - !permit + role: !group users + privilege: [ read, authenticate ] + resource: !webservice + + - !grant + role: !group conjur/authn-oidc/keycloak/users + member: !user alice + """ + + And I am the super-user + And I successfully set OIDC variables + + + Scenario: A valid authorization code and redirect uri to get Conjur access token + Given I get authorization code + When I successfully authenticate via OIDC + Then "alice" is authorized diff --git a/cucumber/authenticators/features/step_definitions/authn_common_steps.rb b/cucumber/authenticators/features/step_definitions/authn_common_steps.rb new file mode 100644 index 0000000000..7d521ed2f4 --- /dev/null +++ b/cucumber/authenticators/features/step_definitions/authn_common_steps.rb @@ -0,0 +1,7 @@ +Given(/^a policy:$/) do |policy| + load_root_policy(policy) +end + +Then(/"(\S+)" is authorized/) do |username| + expect(token_for(username, @response_body)).to be +end diff --git a/cucumber/authenticators/features/step_definitions/authn_ldap_steps.rb b/cucumber/authenticators/features/step_definitions/authn_ldap_steps.rb index 03a64d9a38..d0fbfe138f 100644 --- a/cucumber/authenticators/features/step_definitions/authn_ldap_steps.rb +++ b/cucumber/authenticators/features/step_definitions/authn_ldap_steps.rb @@ -2,9 +2,6 @@ # Uses cucumber's multiline string feature: https://bit.ly/2vpzqJx # -Given(/^a policy:$/) do |policy| - load_root_policy(policy) -end When(/I login via LDAP as (?:\S)+ Conjur user "(\S+)"/) do |username| login_with_ldap(service_id: 'test', account: 'cucumber', @@ -32,10 +29,6 @@ username: username, password: 'BAD_PASSWORD') end -Then(/"(\S+)" is authorized/) do |username| - expect(token_for(username, @response_body)).to be -end - Then(/it is denied/) do expect(unauthorized?).to be true end diff --git a/cucumber/authenticators/features/step_definitions/authn_oidc_steps.rb b/cucumber/authenticators/features/step_definitions/authn_oidc_steps.rb new file mode 100644 index 0000000000..92bd25353d --- /dev/null +++ b/cucumber/authenticators/features/step_definitions/authn_oidc_steps.rb @@ -0,0 +1,11 @@ +Given(/I get authorization code/) do + oidc_authorization_code +end + +Given(/I successfully set OIDC variables/) do + set_oidc_variables +end + +When(/I successfully authenticate via OIDC/) do + authenticate_with_oidc(service_id: 'keycloak', account: 'cucumber') +end diff --git a/cucumber/authenticators/features/support/authenticator_helpers.rb b/cucumber/authenticators/features/support/authenticator_helpers.rb index a9413eaaef..6b9423403e 100644 --- a/cucumber/authenticators/features/support/authenticator_helpers.rb +++ b/cucumber/authenticators/features/support/authenticator_helpers.rb @@ -1,9 +1,20 @@ # frozen_string_literal: true +require 'util/error_class' + # Utility methods for authenticators # module AuthenticatorHelpers + MissingEnvVarirable = ::Util::ErrorClass.new( + 'Environment variable [{0}] is not defined' + ) + + def validated_env_var(var) + raise MissingEnvVarirable, var if ENV[var].blank? + ENV[var] + end + # Mostly to document the mutable variables that are in play. # To at least mitigate the poor design encouraged by the way cucumber # shares state @@ -13,7 +24,7 @@ module AuthenticatorHelpers def login_with_ldap(service_id:, account:, username:, password:) path = "#{conjur_hostname}/authn-ldap/#{service_id}/#{account}/login" get(path, user: username, password: password) - @ldap_auth_key=response_body + @ldap_auth_key = response_body end def authenticate_with_ldap(service_id:, account:, username:, api_key:) @@ -22,6 +33,12 @@ def authenticate_with_ldap(service_id:, account:, username:, api_key:) post(path, api_key) end + def authenticate_with_oidc(service_id:, account:) + path = "#{conjur_hostname}/authn-oidc/#{service_id}/#{account}/authenticate" + payload = { code: oidc_auth_code, redirect_uri: oidc_redirect_uri } + post(path, payload) + end + def token_for(username, token_string) return nil unless http_status == 200 ConjurToken.new(token_string).username == username @@ -44,7 +61,7 @@ def load_root_policy(policy) private - def get(path, options = {}) + def get(path, options = {}) options = options.merge( method: :get, url: path @@ -95,6 +112,42 @@ def full_username(username, account: Conjur.configuration.account) "#{account}:user:#{username}" end + def oidc_client_id + @oidc_client_id ||= validated_env_var('CLIENT_ID') + end + + def oidc_client_secret + @oidc_client_secret ||= validated_env_var('CLIENT_SECRET') + end + + def oidc_provider_uri + @oidc_provider_uri ||= validated_env_var('PROVIDER_URI') + end + + def oidc_redirect_uri + @oidc_redirect_uri ||= validated_env_var('REDIRECT_URI') + end + + def oidc_auth_code + raise 'Authorization code is not initialized' if @oidc_auth_code.blank? + @oidc_auth_code + end + + def set_oidc_variables + path = "cucumber:variable:conjur/authn-oidc/keycloak" + Secret.create resource_id: "#{path}/client-id", value: oidc_client_id + Secret.create resource_id: "#{path}/client-secret", value: oidc_client_secret + Secret.create resource_id: "#{path}/provider-uri", value: oidc_provider_uri + end + + def oidc_authorization_code + path_script = "/authn-oidc/phantomjs/scripts/fetchAuthCode" + authorization_code_file = "cat /authn-oidc/phantomjs/scripts/authorization_code" + + system("sh #{path_script}") + @oidc_auth_code = `#{authorization_code_file}` + end + end World(AuthenticatorHelpers) diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index c72d4d6cc1..a6f17e35f1 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -32,6 +32,7 @@ services: links: - pg:pg - ldap-server + - oidc-keycloak:keycloak cucumber: image: conjur-dev @@ -77,6 +78,43 @@ services: volumes: - ./files/authn-ldap/ldap:/container/service/slapd/assets/config/bootstrap/ldif/custom + oidc-keycloak: + image: jboss/keycloak + environment: + - KEYCLOAK_USER=alice + - KEYCLOAK_PASSWORD=alice + - DB_VENDOR=H2 + - CLIENT_ID=conjurClient + - REDIRECT_URI=http://locallhost.com/ + - CLIENT_SECRET=d7047915-9029-45b8-9bd6-7ec5c2f75e5b + ports: + - "7777:8080" + + volumes: + - ./files/authn-oidc/keycloak/standalone.xml:/opt/jboss/keycloak/standalone/configuration/standalone.xml + - ./files/authn-oidc/keycloak:/authn-oidc/keycloak/scripts + + oidc-phantomjs: + environment: + - KEYCLOAK_USER=alice + - KEYCLOAK_PASSWORD=alice + - CLIENT_ID=conjurClient + - REDIRECT_URI=http://locallhost.com/ + - SCOPE=openid profile + image: wernight/phantomjs + entrypoint: sleep + command: infinity + volumes: + - ./files/authn-oidc/phantomjs:/authn-oidc/phantomjs/scripts + links: + - oidc-keycloak:keycloak + + okta-ldap-agent: + image: weareenvoy/okta-ldap-agent + volumes: + - ./files/okta-ldap-agent/conf:/opt/Okta/OktaLDAPAgent/conf + entrypoint: sleep + command: infinity volumes: authn-local: diff --git a/dev/files/authn-ldap/ldap/ldap.ldif b/dev/files/authn-ldap/ldap/ldap.ldif index 23d8ceedc7..0e0517311e 100644 --- a/dev/files/authn-ldap/ldap/ldap.ldif +++ b/dev/files/authn-ldap/ldap/ldap.ldif @@ -2,8 +2,12 @@ dn: uid=alice,dc=conjur,dc=net cn: Alice sn: Smith uid: alice +givenName: alice +mail: alice@cyberark.com objectClass: person objectClass: uidObject +objectClass: organizationalPerson +objectClass: inetOrgPerson objectClass: top userPassword: alice @@ -11,7 +15,11 @@ dn: uid=charles,dc=conjur,dc=net cn: Charles sn: Berkeley uid: charles +givenName: charles +mail: charles@cyberark.com objectClass: person objectClass: uidObject +objectClass: organizationalPerson +objectClass: inetOrgPerson objectClass: top userPassword: charles diff --git a/dev/files/authn-oidc/keycloak/create_client b/dev/files/authn-oidc/keycloak/create_client new file mode 100755 index 0000000000..7b14ed4561 --- /dev/null +++ b/dev/files/authn-oidc/keycloak/create_client @@ -0,0 +1,13 @@ +#!/bin/sh + + +keycloak/bin/kcreg.sh config credentials \ + --server http://localhost:8080/auth \ + --realm master \ + --user "$KEYCLOAK_USER" \ + --password "$KEYCLOAK_PASSWORD" +keycloak/bin/kcreg.sh create \ + -s clientId="$CLIENT_ID" \ + -s "redirectUris=[\"$REDIRECT_URI\"]" \ + -s "secret=$CLIENT_SECRET" +keycloak/bin/kcreg.sh get "$CLIENT_ID" | jq '.secret' diff --git a/dev/files/authn-oidc/keycloak/fetchCertificate b/dev/files/authn-oidc/keycloak/fetchCertificate new file mode 100755 index 0000000000..bdfdadf0b3 --- /dev/null +++ b/dev/files/authn-oidc/keycloak/fetchCertificate @@ -0,0 +1,3 @@ +#!/bin/sh + +echo | openssl s_client -showcerts -connect keycloak:8443 -servername keycloak 2>/dev/null | openssl x509 -outform PEM > /etc/ssl/certs/keycloak.pem diff --git a/dev/files/authn-oidc/keycloak/standalone.xml b/dev/files/authn-oidc/keycloak/standalone.xml new file mode 100644 index 0000000000..5562169586 --- /dev/null +++ b/dev/files/authn-oidc/keycloak/standalone.xml @@ -0,0 +1,578 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + + + + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + request + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev/files/authn-oidc/phantomjs/fetchAuthCode b/dev/files/authn-oidc/phantomjs/fetchAuthCode new file mode 100755 index 0000000000..a06e2999eb --- /dev/null +++ b/dev/files/authn-oidc/phantomjs/fetchAuthCode @@ -0,0 +1,20 @@ +#!/bin/sh + +echo "Fetching authorization code" + +cd /authn-oidc/phantomjs/scripts +echo "Deleting authorization_code file" +rm -rf authorization_code || true + +echo "Copying file keycloak_login.js" +cp -rf keycloak_login.js keycloak_login_tmp.js + +echo "Replacing user and password in keycloak_login_tmp.js" + +sed -i 's/TO_BE_REPLACED_USER/'"$KEYCLOAK_USER"'/g' keycloak_login_tmp.js +sed -i 's/TO_BE_REPLACED_PASSSWORD/'"$KEYCLOAK_PASSWORD"'/g' keycloak_login_tmp.js + +echo "Running phantomjs script" +phantomjs --ignore-ssl-errors=true keycloak_login_tmp.js + +echo "Script complete!" diff --git a/dev/files/authn-oidc/phantomjs/keycloak_login.js b/dev/files/authn-oidc/phantomjs/keycloak_login.js new file mode 100644 index 0000000000..398dac80ff --- /dev/null +++ b/dev/files/authn-oidc/phantomjs/keycloak_login.js @@ -0,0 +1,93 @@ +var steps=[]; +var testindex = 0; +var loadInProgress = false;//This is set to true when a page is still loading + +/*********SETTINGS*********************/ +var webPage = require('webpage'); + +var page = webPage.create(); +var system = require('system'); +var env = system.env; +page.settings.userAgent = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36'; +page.settings.javascriptEnabled = true; +page.settings.loadImages = false;//Script is much faster with this field set to false +phantom.cookiesEnabled = true; +phantom.javascriptEnabled = true; +/*********SETTINGS END*****************/ + +console.log('All settings loaded, start with execution'); +page.onConsoleMessage = function(msg) { + console.log(msg); +}; +/**********DEFINE STEPS THAT FANTOM SHOULD DO***********************/ +steps = [ + + + function(){ + console.log('Open keycloak home page'); + var authorize_request = "https://keycloak:8443/auth/realms/master/protocol/openid-connect/auth"; + authorize_request = authorize_request + "?client_id=" + env['CLIENT_ID']; + authorize_request = authorize_request + "&response_type=code&response_mode=query"; + authorize_request = authorize_request + "&scope=" + env['SCOPE']; + authorize_request = authorize_request + "&redirect_uri=" + env['REDIRECT_URI']; + console.log('Rest request : ' + authorize_request.toString()); + //http://keycloak:8080/auth/admin + //example to request: http://keycloak:8080/auth/realms/master/protocol/openid-connect/auth?client_id=myclient&response_type=code&response_mode=query&scope=openid profile&redirect_uri=http://locallhost.com/" + page.open(authorize_request.toString(), function(status){ + console.log('Rest request status: ' + status); + }); + }, + + function(){ + console.log('Populate and submit the login form'); + page.evaluate(function(){ + document.getElementById("username").value = "TO_BE_REPLACED_USER"; + document.getElementById("password").value = "TO_BE_REPLACED_PASSSWORD"; + document.getElementById("kc-login").click(); + }); + }, + + function(){ + console.log("Wait keycloak to login user"); + var fs = require('fs'); + var result = page.evaluate(function() { + return document.querySelectorAll("html")[0].outerHTML; + }); + var code = result.substring(result.indexOf('code=') + 5, result.length); + code = code.substring(0, code.indexOf('>http') - 1); + console.log('authorization code=' + code); + fs.write('authorization_code',code,'w'); + }, +]; +/**********END STEPS THAT FANTOM SHOULD DO***********************/ + +//Execute steps one by one +interval = setInterval(executeRequestsStepByStep,50); + +function executeRequestsStepByStep(){ + if (loadInProgress == false && typeof steps[testindex] == "function") { + //console.log("step " + (testindex + 1)); + steps[testindex](); + testindex++; + } + if (typeof steps[testindex] != "function") { + console.log("test complete!"); + phantom.exit(); + } +} + +/** + * These listeners are very important in order to phantom work properly. Using these listeners, we control loadInProgress marker which controls, weather a page is fully loaded. + * Without this, we will get content of the page, even a page is not fully loaded. + */ +page.onLoadStarted = function() { + loadInProgress = true; + console.log('Loading started'); +}; +page.onLoadFinished = function() { + loadInProgress = false; + console.log('Loading finished'); +}; +page.onConsoleMessage = function(msg) { + console.log(msg); +}; diff --git a/dev/files/authn-oidc/policy.yml b/dev/files/authn-oidc/policy.yml new file mode 100644 index 0000000000..5813981e52 --- /dev/null +++ b/dev/files/authn-oidc/policy.yml @@ -0,0 +1,56 @@ +- !policy + id: conjur/authn-oidc/okta + body: + - !webservice + annotations: + description: Authentication service for Okta, based on Open ID Connect. + + - !variable + id: client-id + + - !variable + id: client-secret + + - !variable + id: provider-uri + + - !group users + + - !permit + role: !group users + privilege: [ read, authenticate ] + resource: !webservice + +- !user alice +- !user bob + +- !grant + role: !group conjur/authn-oidc/okta/users + member: !user alice + +- !policy + id: conjur/authn-oidc/keycloak + body: + - !webservice + annotations: + description: Authentication service for Keycloak, based on Open ID Connect. + + - !variable + id: client-id + + - !variable + id: client-secret + + - !variable + id: provider-uri + + - !group users + + - !permit + role: !group users + privilege: [ read, authenticate ] + resource: !webservice + +- !grant + role: !group conjur/authn-oidc/keycloak/users + member: !user alice diff --git a/dev/files/okta-ldap-agent/conf/OktaLDAPAgent.conf b/dev/files/okta-ldap-agent/conf/OktaLDAPAgent.conf new file mode 100644 index 0000000000..130694b1e8 --- /dev/null +++ b/dev/files/okta-ldap-agent/conf/OktaLDAPAgent.conf @@ -0,0 +1,19 @@ +agentId = a53ggwp8hb7qsmTrj0h7 +instanceId = +orgUrl = https://dev-842018.oktapreview.com +token = SX3yqnrknORAZ1QQWB21Nk7IINoYkSGO7fG8LNnCt1pc/FJDLQey9Cz/wVe7DTRo +ldapHost = ldap-server +ldapPort = 389 +ldapAdminDN = cn=admin,dc=conjur,dc=net +ldapAdminPassword = i65qKJ2GKoNd9cUe87fxlg== +ldapDomainId = 0oaggwpg746AxDaLt0h7 +ldapUseSSL = false +ldapSSLPort = 0 +baseDN = dc=conjur,dc=net +proxyEnabled = false +propertyKey = OGwvCgEGUsOupcMZHerMlQAzBJyBiQdfe6w/DeXu2qdsJL0JAAFqMtXqnpPAl7L2 +connectionHealthCheckFrequencyInMinutes = 0 +memoryTrackFrequencyInMinutes = 0 +threadDumpFrequencyInMinutes = 0 +ldapSearchPageSize = 500 +sslPinningEnabled = false diff --git a/dev/start b/dev/start index 22d87eb75a..d77fd19f6f 100755 --- a/dev/start +++ b/dev/start @@ -15,6 +15,8 @@ Usage: start [options] --authn-iam Starts with authn-iam/prod as authenticator + --authn-oidc Starts with authn-oidc/okta as authenticator + -h, --help Shows this help message. EOF exit @@ -25,11 +27,13 @@ unset COMPOSE_PROJECT_NAME # Determine which extra services should be loaded when working with authenticators ENABLE_AUTHN_LDAP=false ENABLE_AUTHN_IAM=false +ENABLE_AUTHN_OIDC=false ENABLE_ROTATORS=false while true ; do case "$1" in --authn-iam ) ENABLE_AUTHN_IAM=true ; shift ;; --authn-ldap ) ENABLE_AUTHN_LDAP=true ; shift ;; + --authn-oidc ) ENABLE_AUTHN_OIDC=true ; shift ;; --rotators ) ENABLE_ROTATORS=true ; shift ;; -h | --help ) print_help ; shift ;; * ) if [ -z "$1" ]; then break; else echo "$1 is not a valid option"; exit 1; fi;; @@ -51,27 +55,90 @@ docker-compose exec conjur bundle docker-compose exec conjur conjurctl db migrate docker-compose exec conjur conjurctl account create cucumber || true +enabled_authenticators="authn" + env_args= if [[ $ENABLE_AUTHN_LDAP = true ]]; then services="$services ldap-server" - env_args="$env_args -e CONJUR_AUTHENTICATORS=authn,authn-ldap/test" env_args="$env_args -e LDAP_URI=ldap://ldap-server:389" env_args="$env_args -e LDAP_BASE=dc=conjur,dc=net" env_args="$env_args -e LDAP_FILTER=(uid=%s)" env_args="$env_args -e LDAP_BINDDN=cn=admin,dc=conjur,dc=net" env_args="$env_args -e LDAP_BINDPW=ldapsecret" + + enabled_authenticators="$enabled_authenticators,authn-ldap/test" + docker-compose exec conjur conjurctl policy load cucumber /src/conjur-server/dev/files/authn-ldap/policy.yml fi +if [[ $ENABLE_AUTHN_OIDC = true ]]; then + services="$services oidc-keycloak oidc-phantomjs client" + docker-compose up -d --no-deps $services + + echo "Configuring Okta as OpenID provider for manual testing" + echo "Configuring Keycloak as OpenID provider for automatic testing" + docker-compose exec conjur conjurctl policy load cucumber /src/conjur-server/dev/files/authn-oidc/policy.yml + + enabled_authenticators="$enabled_authenticators,authn-oidc/okta,authn-oidc/keycloak" + + # For future use, when we will solve the self sign certificate issue with OpenID lib and keycloak + # echo "Initialize keycloak certificate in conjur server" + # docker-compose exec conjur /src/conjur-server/dev/files/authn-oidc/keycloak/fetchCertificate + + echo "Starting Conjur server" + api_key=$(docker-compose exec -T conjur conjurctl \ + role retrieve-key cucumber:user:admin | tr -d '\r') + docker-compose exec $env_args -d conjur conjurctl server + + echo "Sleep 30 sec for Conjur server to start" + sleep 30 + + echo "Setting keycloak variables values in conjur" + KEYCLOAK_CLIENT_ID=$(docker-compose exec oidc-keycloak printenv CLIENT_ID | tr -d '\r') + KEYCLOAK_CLIENT_SECRET=$(docker-compose exec oidc-keycloak printenv CLIENT_SECRET | tr -d '\r') + + docker-compose exec client conjur authn login -u admin -p $api_key + + # add variables' values for keycloak + docker-compose exec client conjur variable values add conjur/authn-oidc/keycloak/client-id $KEYCLOAK_CLIENT_ID + docker-compose exec client conjur variable values add conjur/authn-oidc/keycloak/client-secret $KEYCLOAK_CLIENT_SECRET + docker-compose exec client conjur variable values add conjur/authn-oidc/keycloak/provider-uri "https://keycloak:8443/auth/realms/master" + + # add variables' values for okta + docker-compose exec client conjur variable values add conjur/authn-oidc/okta/client-id 0oagd87pc7rUCknhR0h7 + docker-compose exec client conjur variable values add conjur/authn-oidc/okta/client-secret aDL6DTH7WIE3qsTjnY6H_lYcfMKXK7hCA6AlZEer + docker-compose exec client conjur variable values add conjur/authn-oidc/okta/provider-uri https://dev-842018.oktapreview.com + + echo "Creating OpenID client in keycloack OpenID provider" + docker-compose exec oidc-keycloak /authn-oidc/keycloak/scripts/create_client + echo "keycloack admin console url: http://0.0.0.0:7777/auth/admin" + + echo "Fetch OpenID authrization code from keycloak using phantomjs" + docker-compose exec oidc-phantomjs /authn-oidc/phantomjs/scripts/fetchAuthCode + + echo "Building & configuring Okta-LDAP agent" + if [[ $ENABLE_AUTHN_LDAP = true ]]; then + services="$services okta-ldap-agent" + docker-compose up -d --no-deps $services + + echo "Starting Okta agent service" + Docker exec "$(docker-compose ps -q okta-ldap-agent)" /opt/Okta/OktaLDAPAgent/scripts/OktaLDAPAgent + fi +fi + if [[ $ENABLE_ROTATORS = true ]]; then services="$services testdb cucumber" fi if [[ $ENABLE_AUTHN_IAM = true ]]; then - env_args="$env_args -e CONJUR_AUTHENTICATORS=authn-iam/prod" + enabled_authenticators="$enabled_authenticators,authn-iam/prod" + docker-compose exec conjur conjurctl policy load cucumber /src/conjur-server/dev/files/authn-iam/policy.yml fi +echo "Setting CONJUR_AUTHENTICATORS to: $enabled_authenticators" +env_args="$env_args -e CONJUR_AUTHENTICATORS=$enabled_authenticators" + docker-compose up -d --no-deps $services api_key=$(docker-compose exec -T conjur conjurctl \ diff --git a/spec/app/domain/authentication/strategy_spec.rb b/spec/app/domain/authentication/strategy_spec.rb index 711a22be5c..feb361dd9f 100644 --- a/spec/app/domain/authentication/strategy_spec.rb +++ b/spec/app/domain/authentication/strategy_spec.rb @@ -121,6 +121,15 @@ def input( } end + let (:oidc_user_details) do + double('userInfo', user_info: user_info) + end + + let (:user_info) do + double('userInfo', preferred_username: "alice") + end + + #################################### # Security doubles #################################### @@ -159,7 +168,7 @@ def input( double('TokenFactory', signed_token: a_new_token) end -# ____ _ _ ____ ____ ____ ___ ____ ___ +# ____ _ _ ____ ____ ____ ___ ____ ___ # (_ _)( )_( )( ___) (_ _)( ___)/ __)(_ _)/ __) # )( ) _ ( )__) )( )__) \__ \ )( \__ \ # (__) (_) (_)(____) (__) (____)(___/ (__) (___/ @@ -185,6 +194,51 @@ def input( end end + context "An available oidc authenticator" do + context "that receives valid credentials" do + context "that fails Security checks" do + subject do + Authentication::Strategy.new( + authenticators: authenticators, + security: failing_security, + env: two_authenticator_env, + token_factory: token_factory, + audit_log: nil, + role_cls: nil + ) + end + it "raises an error" do + allow(subject).to receive(:oidc_user_details) { oidc_user_details } + input_ = input(authenticator_name: 'authn-always-pass') + expect{ subject.conjur_token_oidc(input_) }.to raise_error( + /FAKE_SECURITY_ERROR/ + ) + end + end + + context "that passes Security checks" do + subject do + Authentication::Strategy.new( + authenticators: authenticators, + security: passing_security, + env: two_authenticator_env, + token_factory: token_factory, + audit_log: nil, + role_cls: nil + ) + end + it "returns a new token" do + allow(subject).to receive(:oidc_user_details) { oidc_user_details } + allow(subject).to receive(:oidc_validate_credentials) { true } + allow(subject).to receive(:validate_origin) { true } + input_ = input(authenticator_name: 'authn-always-pass') + expect(subject.conjur_token_oidc(input_)).to equal(a_new_token) + end + end + end + + end + context "An available authenticator" do context "that passes Security checks" do subject do diff --git a/spec/routing/authn_routing_spec.rb b/spec/routing/authn_routing_spec.rb index c8b289f1f7..53677dcefd 100644 --- a/spec/routing/authn_routing_spec.rb +++ b/spec/routing/authn_routing_spec.rb @@ -12,7 +12,24 @@ id: 'kevin.gilpin@inscitiv.com' ) end - + + it "routes POST /authn-oidc/the-service/the-account/authenticate to authenticate#authenticate_oidc" do + expect(post: '/authn-oidc/the-service/the-account/authenticate').to route_to( + controller: 'authenticate', + action: 'authenticate_oidc', + service_id: 'the-service', + account: 'the-account' + ) + end + + it "routes POST /authn-oidc/the-account/authenticate to authenticate#authenticate_oidc" do + expect(post: '/authn-oidc/the-account/authenticate').to route_to( + controller: 'authenticate', + action: 'authenticate_oidc', + account: 'the-account' + ) + end + it "routes PUT /authn/the-account/password to credentials#update_password" do expect(put: '/authn/the-account/password').to route_to( controller: 'credentials', diff --git a/tmp.txt b/tmp.txt new file mode 100644 index 0000000000..51c7e46771 --- /dev/null +++ b/tmp.txt @@ -0,0 +1,53 @@ +CONNECTED(00000003) +--- +Certificate chain + 0 s:/CN=localhost + i:/CN=localhost +--- +Server certificate +-----BEGIN CERTIFICATE----- +MIICqTCCAZOgAwIBAgIJAI/amC53nOdmMAsGCSqGSIb3DQEBCzAUMRIwEAYDVQQD +Ewlsb2NhbGhvc3QwIhgPMjAxODA4MzAxMTQ3MzFaGA8yMDI4MDgyNzExNDczMVow +FDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAhcYN10730aec+9+oGRwXQ2FgCd9fLWvolYS8Ed6q7Jer9OCKuO5+6e1X +Y/9EDzX7aTiIM6cJtLeX+sWmfpD3GhtesK2otUcoZj1vrl2lEvkd9xpQMVJNo1r2 +ztjZMsmWvKsFDwWrSj9ANrzwmOGYUJ8/c0dNm25rrVBx+tdfqe96zKSTT484rJLA +ng8RRuzlMfHFxBp2BCBb+9r9SKE19QNuRv4BIWGD8O0zICnqDhp0wz1XGfgor/eq +WXtkQYcPA93zlqGoUY2DvFWapU8e3E71T4mJzi5gojEJKFyboV84LEm7Ynn3gPgY +e3AQtzz6wduAvy8UoGfhkTvJ+0246wIDAQABMAsGCSqGSIb3DQEBCwOCAQEAgTpz +Je9uJJzvi+HY8oTjyWucSgDIPDz4XP7LLDTACSCT3c+PqnbMKIEJRohHhPwjVY7P +NaoNhkl1x8E6sIPMLymsZot4/JmIQeAKkbW88xg7re7DsbNT61klwDqr4X2bD8+J +mMpuK4AmALyK1Y8cbjzrFftA4Y5vz41gAy6LWBcm28f5xZTItVqehIHKr2gLOErm +XR51jKhhOU44em5Gl5lTQSvnFKZ/CqfAVmKFtgNwUBwyHF4BDjdr+PcIXgb2oBBF +fdwKgipDfZ1xe9xK3v33PGE4preGDKQSmyMCqBTNsd2El8DCCgBx8YK2AsYZ6/h8 +juHWH+QItvONndXylQ== +-----END CERTIFICATE----- +subject=/CN=localhost +issuer=/CN=localhost +--- +No client certificate CA names sent +Peer signing digest: SHA512 +Server Temp Key: ECDH, P-256, 256 bits +--- +SSL handshake has read 1169 bytes and written 431 bytes +--- +New, TLSv1/SSLv3, Cipher is ECDHE-RSA-AES256-GCM-SHA384 +Server public key is 2048 bit +Secure Renegotiation IS supported +Compression: NONE +Expansion: NONE +No ALPN negotiated +SSL-Session: + Protocol : TLSv1.2 + Cipher : ECDHE-RSA-AES256-GCM-SHA384 + Session-ID: 5B87DB78C27633657DBB42AA6A1B109E904B6BD228B0B411365D93FC8F7BAB7A + Session-ID-ctx: + Master-Key: 45EB6FD97CA93028F1E4FE0E181C55119000AE820FBDEDD88AF4C35CB17B426F10E96A90D9D852776F77E13B3FC57EAF + Key-Arg : None + PSK identity: None + PSK identity hint: None + SRP username: None + Start Time: 1535630200 + Timeout : 300 (sec) + Verify return code: 18 (self signed certificate) +---