Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: OIDC refresh token support #2664

Closed
wants to merge 9 commits into from
9 changes: 8 additions & 1 deletion app/controllers/authenticate_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ def oidc_authenticate_code_redirect
# params. This will likely need to be done via the Handler.
params.permit!

auth_token = Authentication::Handler::AuthenticationHandler.new(
auth_token, headers_h = Authentication::Handler::AuthenticationHandler.new(
authenticator_type: params[:authenticator]
).call(
parameters: params.to_hash.symbolize_keys,
request_ip: request.ip
)
Comment on lines +12 to 17
Copy link
Contributor Author

@john-odonnell john-odonnell Oct 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconsidering if this is the interface we want. Authn-OIDC V2 is our only auth scheme that needs to set a response header - this means each other auth scheme converted to use the AuthenticationHandler pattern will be returning an empty set of headers.

To avoid passing the response object everywhere, I experimented with passing a lambda as an optional parameter to the call method, so that while other auth schemes can do this:

auth_token = Authentication::Handler::AuthenticationHandler.new(
  authenticator_type: params[:authenticator]
).call(
  parameters: params.to_hash.symbolize_keys,
  request_ip: request.ip
)

... Authn-OIDC V2 could do this:

auth_token = Authentication::Handler::AuthenticationHandler.new(
  authenticator_type: params[:authenticator]
).call(
  parameters: params.to_hash.symbolize_keys,
  request_ip: request.ip,
  set_header: lambda { |key, value|
    response.set_header(key, value) unless key.blank? or value.blank?
  }
)

... and the set_header block could be used in the OIDC-V2-specific Authentication::AuthnOidc::V2::Strategy class.


set_headers(headers_h)
render_authn_token(auth_token)
rescue => e
log_backtrace(e)
Expand Down Expand Up @@ -248,6 +249,12 @@ def render_authn_token(authn_token)
render(content_type => authn_token)
end

def set_headers(headers_h)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not prefix writer method names with set_.

headers_h.each { |key, value|
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using {...} for multi-line blocks.

response.set_header(key, value.to_s) unless key.blank? || value.blank?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AuthenticateController#set_headers refers to 'value' more than self (maybe move it to another class?)

}
end

def log_audit_success(
authn_params:,
audit_event_class:
Expand Down
41 changes: 37 additions & 4 deletions app/domain/authentication/authn_oidc/v2/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,28 @@ def oidc_client
end
end

def callback(code:, nonce:, code_verifier:)
def get_token_with_code(code:, nonce:, code_verifier:)
oidc_client.authorization_code = code
id_token, refresh_token = get_token_pair(code_verifier)
decoded_id_token = decode_id_token(id_token)
verify_id_token(decoded_id_token, nonce)

[decoded_id_token, refresh_token]
end

def get_token_with_refresh_token(refresh_token:, nonce:)
oidc_client.refresh_token = refresh_token
id_token, refresh_token = get_token_pair(nil)
decoded_id_token = decode_id_token(id_token)
verify_id_token(decoded_id_token, nonce, refresh: true)

[decoded_id_token, refresh_token]
end

def get_token_pair(code_verifier)
begin
bearer_token = oidc_client.access_token!(
scope: true,
scope: @authenticator.scope,
client_auth_method: :basic,
code_verifier: code_verifier
)
Expand All @@ -54,11 +71,19 @@ def callback(code:, nonce:, code_verifier:)
when /The authorization code is invalid or has expired/
raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed,
'Authorization code is invalid or has expired'
when /The refresh token is invalid or expired/
raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed,
'Refresh token is invalid or has expired'
end
raise e
end

id_token = bearer_token.id_token || bearer_token.access_token
refresh_token = bearer_token.refresh_token
[id_token, refresh_token]
end

def decode_id_token(id_token)
begin
attempts ||= 0
decoded_id_token = @oidc_id_token.decode(
Expand All @@ -76,11 +101,20 @@ def callback(code:, nonce:, code_verifier:)
retry
end

decoded_id_token
end

def verify_id_token(decoded_id_token, nonce, refresh: false)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication::AuthnOidc::V2::Client#verify_id_token has boolean parameter 'refresh'

expected_nonce = nonce
if refresh && decoded_id_token.raw_attributes['nonce'].nil?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication::AuthnOidc::V2::Client#verify_id_token is controlled by argument 'refresh'

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication::AuthnOidc::V2::Client#verify_id_token performs a nil-check

expected_nonce = nil
end

begin
decoded_id_token.verify!(
issuer: @authenticator.provider_uri,
client_id: @authenticator.client_id,
nonce: nonce
nonce: expected_nonce
)
rescue OpenIDConnect::ResponseObject::IdToken::InvalidNonce
raise Errors::Authentication::AuthnOidc::TokenVerificationFailed,
Expand All @@ -92,7 +126,6 @@ def callback(code:, nonce:, code_verifier:)
raise Errors::Authentication::AuthnOidc::TokenVerificationFailed,
e.message
end
decoded_id_token
end

def discovery_information(invalidate: false)
Expand Down
15 changes: 8 additions & 7 deletions app/domain/authentication/authn_oidc/v2/strategy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,19 @@ def callback(args)
end
end

identity = resolve_identity(
jwt: @client.callback(
code: args[:code],
nonce: args[:nonce],
code_verifier: args[:code_verifier]
)
jwt, refresh_token = @client.get_token_with_code(
code: args[:code],
nonce: args[:nonce],
code_verifier: args[:code_verifier]
)
identity = resolve_identity(jwt: jwt)
unless identity.present?
raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty,
@authenticator.claim_mapping
end
identity

return [identity, {}] if refresh_token.nil?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication::AuthnOidc::V2::Strategy#callback performs a nil-check

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add empty line after guard clause.

[identity, { 'X-OIDC-Refresh-Token' => refresh_token }]
end

def resolve_identity(jwt:)
Expand Down
12 changes: 8 additions & 4 deletions app/domain/authentication/handler/authentication_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ def call(parameters:, request_ip:)
)
end

identity, headers_h = @strategy.new(
authenticator: authenticator
).callback(parameters)

role = @identity_resolver.new.call(
identity: @strategy.new(
authenticator: authenticator
).callback(parameters),
identity: identity,
account: parameters[:account],
allowed_roles: @role.that_can(
:authenticate,
Expand All @@ -65,10 +67,12 @@ def call(parameters:, request_ip:)

log_audit_success(authenticator, role, request_ip, @authenticator_type)

TokenFactory.new.signed_token(
token = TokenFactory.new.signed_token(
account: parameters[:account],
username: role.role_id.split(':').last
)

[token, headers_h]
rescue => e
log_audit_failure(parameters[:account], parameters[:service_id], request_ip, @authenticator_type, e)
handle_error(e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def get(path, options = {})
)
result = RestClient::Request.execute(options)
@response_body = result.body
@response_headers = result.headers
@http_status = result.code
result
rescue RestClient::Exception => e
Expand Down
10 changes: 9 additions & 1 deletion cucumber/authenticators_oidc/features/authn_oidc_v2.feature
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ Feature: OIDC Authenticator V2 - Users can authenticate with OIDC authenticator
cucumber:user:alice successfully authenticated with authenticator authn-oidc service cucumber:webservice:conjur/authn-oidc/keycloak2
"""

@smoke
Scenario: A valid code to get Conjur access token and OIDC refresh token
Given I enable OIDC V2 refresh token flows for "keycloak2"
And I fetch a code for username "alice" and password "alice"
When I authenticate via OIDC V2 with code
Then user "alice" has been authorized by Conjur
And The authentication response includes header "X-OIDC-Refresh-Token"

@smoke
Scenario: A valid code with email as claim mapping
Given I extend the policy with:
Expand Down Expand Up @@ -189,7 +197,7 @@ Feature: OIDC Authenticator V2 - Users can authenticate with OIDC authenticator
Given I save my place in the log file
And I fetch a code for username "[email protected]" and password "alice"
When I authenticate via OIDC V2 with code "bad-code"
Then it is a bad request
Then it is unauthorized
And The following appears in the log after my savepoint:
"""
Rack::OAuth2::Client::Error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@
)
end

Given(/^I enable OIDC V2 refresh token flows for "([^"]*)"$/) do |service_id|
create_oidc_secret("provider-scope", "#{oidc_scope},offline_access", service_id)
end

When(/^I authenticate via OIDC V2 with code and service-id "([^"]*)"$/) do |service_id|
authenticate_with_oidc_code(
service_id: service_id,
Expand All @@ -210,6 +214,11 @@
)
end

Then(/^The authentication response includes header "([^"]*)"$/) do |header|
header_sym = header.parameterize.underscore.to_sym
expect(@response_headers[header_sym]).not_to be_nil
end

Then(/^The Okta user has been authorized by Conjur/) do
username = ENV['OKTA_USERNAME']
expect(retrieved_access_token.username).to eq(username)
Expand Down
Loading