Skip to content

Commit

Permalink
ONYX-7022: Add JWT authenticator
Browse files Browse the repository at this point in the history
JWT generic authenticator - stage #1
  • Loading branch information
tzheleznyak authored and micahlee committed Jun 14, 2021
1 parent 6fbfe06 commit 99134e0
Show file tree
Hide file tree
Showing 69 changed files with 5,654 additions and 68 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Added `conjurctl configuration apply` command restart the Conjur process and
pick up changes to the configuration file.
[cyberark/conjur#2171](https://github.com/cyberark/conjur/issues/2171)
- The JWT Authenticator (`authn-jwt`) supports authenticating third-party vendors that utilize JWT.
See [design](https://github.com/cyberark/conjur/blob/master/design/authenticators/authn_jwt/authn_jwt_solution_design.md)

### Fixed
- Fix bug where running `conjurctl server` or `conjurctl account create` with
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Expand Up @@ -423,6 +423,7 @@ Below is the list of the available Cucumber suites:
* authenticators_azure
* authenticators_config
* authenticators_gcp
* authenticators_jwt
* authenticators_ldap
* authenticators_oidc
* authenticators_status
Expand Down
5 changes: 5 additions & 0 deletions Jenkinsfile
Expand Up @@ -191,6 +191,7 @@ pipeline {
authenticators_azure/cucumber_results.html,
authenticators_ldap/cucumber_results.html,
authenticators_oidc/cucumber_results.html,
authenticators_jwt/cucumber_results.html,
authenticators_status/cucumber_results.html
policy/cucumber_results.html,
rotators/cucumber_results.html
Expand Down Expand Up @@ -457,6 +458,7 @@ pipeline {
authenticators_azure/cucumber_results.html,
authenticators_ldap/cucumber_results.html,
authenticators_oidc/cucumber_results.html,
authenticators_jwt/cucumber_results.html,
authenticators_gcp/cucumber_results.html,
authenticators_status/cucumber_results.html,
policy/cucumber_results.html,
Expand Down Expand Up @@ -621,6 +623,9 @@ def runConjurTests() {
"OIDC Authenticator - ${env.STAGE_NAME}": {
sh 'ci/test authenticators_oidc'
},
"JWT Authenticator - ${env.STAGE_NAME}": {
sh 'ci/test authenticators_jwt'
},
"Policy - ${env.STAGE_NAME}": {
sh 'ci/test policy'
},
Expand Down
58 changes: 50 additions & 8 deletions app/controllers/authenticate_controller.rb
Expand Up @@ -31,6 +31,18 @@ def status
render(status_failure_response(e))
end

def authn_jwt_status
params[:authenticator] = "authn-jwt"
Authentication::AuthnJwt::ValidateStatus.new.call(
authenticator_status_input: status_input,
enabled_authenticators: Authentication::InstalledAuthenticators.enabled_authenticators_str
)
render(json: { status: "ok" })
rescue => e
log_backtrace(e)
render(status_failure_response(e))
end

def update_config
body_params = Rack::Utils.parse_nested_query(request.body.read)

Expand Down Expand Up @@ -72,14 +84,18 @@ def authenticate(input = authenticator_input)
authenticators: installed_authenticators,
enabled_authenticators: Authentication::InstalledAuthenticators.enabled_authenticators_str
)
content_type = :json
if encoded_response?
logger.debug(LogMessages::Authentication::EncodedJWTResponse.new)
content_type = :plain
authn_token = ::Base64.strict_encode64(authn_token.to_json)
response.set_header("Content-Encoding", "base64")
end
render(content_type => authn_token)
render_authn_token(authn_token)
rescue => e
handle_authentication_error(e)
end

def authenticate_jwt
params[:authenticator] = "authn-jwt"
authn_token = Authentication::AuthnJwt::OrchestrateAuthentication.new.call(
authenticator_input: authenticator_input_without_credentials,
enabled_authenticators: Authentication::InstalledAuthenticators.enabled_authenticators_str
)
render_authn_token(authn_token)
rescue => e
handle_authentication_error(e)
end
Expand Down Expand Up @@ -119,6 +135,32 @@ def authenticator_input
)
end

# create authenticator input without reading the request body
# request body can be relatively large
# authenticator will read it after basic validation check
def authenticator_input_without_credentials
Authentication::AuthenticatorInput.new(
authenticator_name: params[:authenticator],
service_id: params[:service_id],
account: params[:account],
username: params[:id],
credentials: nil,
client_ip: request.ip,
request: request
)
end

def render_authn_token(authn_token)
content_type = :json
if encoded_response?
logger.debug(LogMessages::Authentication::EncodedJWTResponse.new)
content_type = :plain
authn_token = ::Base64.strict_encode64(authn_token.to_json)
response.set_header("Content-Encoding", "base64")
end
render(content_type => authn_token)
end

def k8s_inject_client_cert
# TODO: add this to initializer
Authentication::AuthnK8s::InjectClientCert.new.(
Expand Down
22 changes: 22 additions & 0 deletions app/domain/authentication/authn_jwt/authentication_parameters.rb
@@ -0,0 +1,22 @@
module Authentication
module AuthnJwt
# Data class to store data regarding jwt token that is needed during the jwt authentication process
class AuthenticationParameters
attr_accessor :decoded_token, :jwt_token
attr_reader :authenticator_name, :service_id, :account, :username, :client_ip, :request

def initialize(authentication_input:, jwt_token:)
@authenticator_name = authentication_input.authenticator_name
@service_id = authentication_input.service_id
@account = authentication_input.account
@username = authentication_input.username
@client_ip = authentication_input.client_ip
@jwt_token = jwt_token
end

def authn_jwt_variable_id_prefix
"#{@account}:variable:conjur/#{@authenticator_name}/#{@service_id}"
end
end
end
end
143 changes: 143 additions & 0 deletions app/domain/authentication/authn_jwt/authenticator.rb
@@ -0,0 +1,143 @@
require 'command_class'

module Authentication
module AuthnJwt
# Generic JWT authenticator that receive JWT vendor configuration and uses to validate that the authentication
# request is valid, and return conjur authn token accordingly
Authenticator = CommandClass.new(
dependencies: {
token_factory: TokenFactory.new,
logger: Rails.logger,
audit_log: ::Audit.logger,
validate_origin: ::Authentication::ValidateOrigin.new,
role_class: ::Role,
webservice_class: ::Authentication::Webservice,
validate_role_can_access_webservice: ::Authentication::Security::ValidateRoleCanAccessWebservice.new,
role_id_class: Audit::Event::Authn::RoleId
},
inputs: %i[jwt_configuration authenticator_input]
) do
extend(Forwardable)
def_delegators(:@authenticator_input, :account, :username, :client_ip, :authenticator_name, :service_id)

def call
validate_and_decode_token
get_jwt_identity_from_request
validate_host_has_access_to_webservice
validate_origin
validate_restrictions
audit_success
@logger.debug(LogMessages::Authentication::AuthnJwt::JwtAuthenticationPassed.new)
new_token
rescue => e
audit_failure(e)
raise e
end

private

def validate_and_decode_token
@logger.debug(LogMessages::Authentication::AuthnJwt::CallingValidateAndDecodeToken.new)
@jwt_configuration.validate_and_decode_token
@logger.debug(LogMessages::Authentication::AuthnJwt::ValidateAndDecodeTokenPassed.new)
end

def get_jwt_identity_from_request
@logger.debug(LogMessages::Authentication::AuthnJwt::CallingGetJwtIdentity.new)
jwt_identity
@logger.info(LogMessages::Authentication::AuthnJwt::FoundJwtIdentity.new(jwt_identity))
end

def jwt_identity
@jwt_identity ||= @jwt_configuration.jwt_identity
end

def validate_host_has_access_to_webservice
@validate_role_can_access_webservice.(
webservice: webservice,
account: account,
user_id: jwt_identity,
privilege: PRIVILEGE_AUTHENTICATE
)
end

def validate_origin
@validate_origin.(
account: account,
username: jwt_identity,
client_ip: client_ip
)
end

def validate_restrictions
@logger.debug(LogMessages::Authentication::AuthnJwt::CallingValidateRestrictions.new)
@jwt_configuration.validate_restrictions
@logger.debug(LogMessages::Authentication::AuthnJwt::ValidateRestrictionsPassed.new)
end

def audit_success
@audit_log.log(
::Audit::Event::Authn::Authenticate.new(
authenticator_name: authenticator_name,
service: webservice,
role_id: audit_role_id,
client_ip: client_ip,
success: true,
error_message: nil
)
)
end

def audit_failure(err)
@audit_log.log(
::Audit::Event::Authn::Authenticate.new(
authenticator_name: authenticator_name,
service: webservice,
role_id: audit_role_id,
client_ip: client_ip,
success: false,
error_message: err.message
)
)
end

def identity_role
@identity_role ||= @role_class.by_login(
jwt_identity,
account: account
)
end

# If there is no jwt identity so role and username are nil
def audit_role_id
return @audit_role_id if @audit_role_id
# We use '@jwt_identity' and not 'jwt_identity' so that we don't call the function in case 'validate_and_decode'
# failed. In such a case, we want to still be able to log an audit message without the role and username.
if @jwt_identity
role = identity_role
username = jwt_identity
end
@audit_role_id = @role_id_class.new(
role: role,
account: account,
username: username
).to_s
end

def webservice
@webservice ||= @webservice_class.new(
account: account,
authenticator_name: authenticator_name,
service_id: service_id
)
end

def new_token
@token_factory.signed_token(
account: account,
username: jwt_identity
)
end
end
end
end
28 changes: 28 additions & 0 deletions app/domain/authentication/authn_jwt/consts.rb
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Authentication
module AuthnJwt
PROVIDER_URI_RESOURCE_NAME = "provider-uri"
JWKS_URI_RESOURCE_NAME = "jwks-uri"
PROVIDER_URI_INTERFACE_NAME = PROVIDER_URI_RESOURCE_NAME.freeze
JWKS_URI_INTERFACE_NAME = JWKS_URI_RESOURCE_NAME.freeze
ISSUER_RESOURCE_NAME = "issuer"
TOKEN_APP_PROPERTY_VARIABLE = "token-app-property"
IDENTITY_NOT_RETRIEVED_YET = "Identity not retrieved yet"
URL_IDENTITY_PROVIDER_INTERFACE_NAME = "url-identity-provider"
TOKEN_IDENTITY_PROVIDER_INTERFACE_NAME = "token-identity-provider"
PRIVILEGE_AUTHENTICATE="authenticate"
ISS_CLAIM_NAME = "iss"
EXP_CLAIM_NAME = "exp"
NBF_CLAIM_NAME = "nbf"
IAT_CLAIM_NAME = "iat"
RSA_ALGORITHMS = %w[RS256 RS384 RS512].freeze
ECDSA_ALGORITHMS = %w[ES256 ES384 ES512].freeze
SUPPORTED_ALGORITHMS = (RSA_ALGORITHMS + ECDSA_ALGORITHMS)
CACHE_REFRESHES_PER_INTERVAL = 10
CACHE_RATE_LIMIT_INTERVAL = 300
CACHE_MAX_CONCURRENT_REQUESTS = 3
MANDATORY_CLAIMS = [EXP_CLAIM_NAME].freeze
OPTIONAL_CLAIMS = [ISS_CLAIM_NAME, NBF_CLAIM_NAME, IAT_CLAIM_NAME].freeze
end
end
@@ -0,0 +1,74 @@
require 'command_class'

module Authentication
module AuthnJwt
module IdentityProviders
# Factory for jwt identity providers.
# If Identity variable is configured factory return the decoded_token_provider
# If the Identity variable is not configured and there is account field in url factory returns url provider
# If the above conditions are not met exception is raised
CreateIdentityProvider = CommandClass.new(
dependencies: {
identity_from_url_provider_class: Authentication::AuthnJwt::IdentityProviders::IdentityFromUrlProvider,
identity_from_decoded_token_class: Authentication::AuthnJwt::IdentityProviders::IdentityFromDecodedTokenProvider,
logger: Rails.logger
},
inputs: %i[authentication_parameters]
) do

def call
validate_identity_configuration
create_identity_provider
end

private

def create_identity_provider
@logger.debug(LogMessages::Authentication::AuthnJwt::SelectingIdentityProviderInterface.new)

if identity_from_decoded_token_provider.identity_available?
@logger.info(
LogMessages::Authentication::AuthnJwt::SelectedIdentityProviderInterface.new(
TOKEN_IDENTITY_PROVIDER_INTERFACE_NAME
)
)
identity_from_decoded_token_provider
elsif identity_from_url_provider.identity_available?
@logger.info(
LogMessages::Authentication::AuthnJwt::SelectedIdentityProviderInterface.new(
URL_IDENTITY_PROVIDER_INTERFACE_NAME
)
)
identity_from_url_provider
end
end

def identity_from_decoded_token_provider
@identity_from_decoded_token_provider ||= @identity_from_decoded_token_class.new(
authentication_parameters: @authentication_parameters
)
end

def identity_from_url_provider
@identity_from_url_provider ||= @identity_from_url_provider_class.new(
authentication_parameters: @authentication_parameters
)
end

def validate_identity_configuration
if multiple_identities_configured || no_identities_configured
raise Errors::Authentication::AuthnJwt::IdentityMisconfigured
end
end

def multiple_identities_configured
identity_from_decoded_token_provider.identity_available? && identity_from_url_provider.identity_available?
end

def no_identities_configured
!identity_from_decoded_token_provider.identity_available? && !identity_from_url_provider.identity_available?
end
end
end
end
end

0 comments on commit 99134e0

Please sign in to comment.