Skip to content

Commit

Permalink
Authn OIDC V2: Enable refresh token response for auth code exchange
Browse files Browse the repository at this point in the history
  • Loading branch information
john-odonnell committed Oct 13, 2022
1 parent b35fc96 commit e8ab69d
Show file tree
Hide file tree
Showing 10 changed files with 329 additions and 32 deletions.
3 changes: 2 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, refresh_token = Authentication::Handler::AuthenticationHandler.new(
authenticator_type: params[:authenticator]
).call(
parameters: params.to_hash.symbolize_keys,
request_ip: request.ip
)

response.set_header('X-OIDC-Refresh-Token', refresh_token) unless refresh_token.nil?
render_authn_token(auth_token)
rescue => e
log_backtrace(e)
Expand Down
3 changes: 2 additions & 1 deletion app/domain/authentication/authn_oidc/v2/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def callback(code:)
nonce: @authenticator.nonce
)
id_token = bearer_token.id_token || bearer_token.access_token
refresh_token = bearer_token.refresh_token

begin
attempts ||= 0
Expand All @@ -72,7 +73,7 @@ def callback(code:)
client_id: @authenticator.client_id,
nonce: @authenticator.nonce
)
decoded_id_token
[decoded_id_token, refresh_token]
rescue OpenIDConnect::ValidationFailed => e
raise Errors::Authentication::AuthnOidc::TokenVerificationFailed, e.message
end
Expand Down
9 changes: 3 additions & 6 deletions app/domain/authentication/authn_oidc/v2/strategy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,13 @@ def callback(args)
# TODO: Check that `code` and `state` attributes are present
raise Errors::Authentication::AuthnOidc::StateMismatch unless args[:state] == @authenticator.state

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

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

identity, refresh_token = @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 @@ -72,10 +74,10 @@ def call(parameters:, request_ip:)

log_audit_success(authenticator, role, request_ip, @authenticator_type)

TokenFactory.new.signed_token(
[TokenFactory.new.signed_token(
account: parameters[:account],
username: role.role_id.split(':').last
)
), refresh_token]
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 @@ -76,6 +76,7 @@ def get(path, options = {})
)
result = RestClient::Request.execute(options)
@response_body = result.body
@response_headers = result.headers
@http_status = result.code
rescue RestClient::Exception => e
@rest_client_error = e
Expand Down
8 changes: 8 additions & 0 deletions cucumber/authenticators_oidc/features/authn_oidc_v2.feature
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,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
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,22 @@
create_oidc_secret("provider-scope", oidc_scope, service_id)
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_code_with_oidc(
service_id: service_id,
account: AuthnOidcHelper::ACCOUNT
)
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
67 changes: 53 additions & 14 deletions spec/app/domain/authentication/authn-oidc/v2/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@
require 'spec_helper'

RSpec.describe(Authentication::AuthnOidc::V2::Client) do
let(:authn_config) do
{
:provider_uri => 'https://dev-92899796.okta.com/oauth2/default',
:redirect_uri => 'http://localhost:3000/authn-oidc/okta-2/cucumber/authenticate',
:client_id => '0oa3w3xig6rHiu9yT5d7',
:client_secret => 'e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj',
:claim_mapping => 'foo',
:nonce => '1656b4264b60af659fce',
:state => 'state',
:account => 'bar',
:service_id => 'baz'
}
end

let(:authenticator) do
Authentication::AuthnOidc::V2::DataObjects::Authenticator.new(
provider_uri: 'https://dev-92899796.okta.com/oauth2/default',
redirect_uri: 'http://localhost:3000/authn-oidc/okta-2/cucumber/authenticate',
client_id: '0oa3w3xig6rHiu9yT5d7',
client_secret: 'e349BMTTIpLO-rPuPqLLkLyH_pO-loUzhIVJCrHj',
claim_mapping: 'foo',
nonce: '1656b4264b60af659fce',
state: 'state',
account: 'bar',
service_id: 'baz'
**authn_config
)
end

Expand All @@ -29,13 +35,16 @@
# Because JWT tokens have an expiration timeframe, we need to hold
# time constant after caching the request.
travel_to(Time.parse("2022-06-30 16:42:17 +0000")) do
token = client.callback(
id_token, refresh_token = client.callback(
code: 'qdDm7On1dEEzNmMlk2bF7IcOF8gCgfvgMCMXXXDlYEE'
)
expect(token).to be_a_kind_of(OpenIDConnect::ResponseObject::IdToken)
expect(token.raw_attributes['nonce']).to eq('1656b4264b60af659fce')
expect(token.raw_attributes['preferred_username']).to eq('[email protected]')
expect(token.aud).to eq('0oa3w3xig6rHiu9yT5d7')

expect(id_token).to be_a_kind_of(OpenIDConnect::ResponseObject::IdToken)
expect(id_token.raw_attributes['nonce']).to eq('1656b4264b60af659fce')
expect(id_token.raw_attributes['preferred_username']).to eq('[email protected]')
expect(id_token.aud).to eq('0oa3w3xig6rHiu9yT5d7')

expect(refresh_token).to be_nil
end
end
end
Expand Down Expand Up @@ -97,6 +106,36 @@
end
end
end

context 'when refresh token flow is enabled' do
# The 'offline_access' scope enables Okta's refresh token flow
let(:authenticator) do
Authentication::AuthnOidc::V2::DataObjects::Authenticator.new(
**authn_config.merge!({ :provider_scope => 'offline_access' })
)
end

context 'when credentials are valid' do
it 'returns valid ID and refresh tokens', vcr: 'authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials_and_refresh' do
# Because JWT tokens have an expiration timeframe, we need to hold
# time constant after caching the request.
travel_to(Time.parse("2022-06-30 16:42:17 +0000")) do
id_token, refresh_token = client.callback(
code: 'qdDm7On1dEEzNmMlk2bF7IcOF8gCgfvgMCMXXXDlYEE'
)

expect(id_token).to be_a_kind_of(OpenIDConnect::ResponseObject::IdToken)
expect(id_token.raw_attributes['nonce']).to eq('1656b4264b60af659fce')
expect(id_token.raw_attributes['preferred_username']).to eq('[email protected]')
expect(id_token.aud).to eq('0oa3w3xig6rHiu9yT5d7')

expect(refresh_token).not_to be_nil
expect(refresh_token).to be_a_kind_of(String)
expect(refresh_token).to eq('kXMJFtgtaEpOGn0Zk2x15i8umXIWp4aqY1Mh7zscfGI')
end
end
end
end
end

describe '.oidc_client' do
Expand Down
31 changes: 26 additions & 5 deletions spec/app/domain/authentication/authn-oidc/v2/strategy_rspec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
RSpec.describe(' Authentication::AuthnOidc::V2::Strategy') do

let(:jwt) { double(raw_attributes: { claim_mapping: "alice" }) }
let(:refresh_token) { 'kXMJFtgtaEpOGn0Zk2x15i8umXIWp4aqY1Mh7zscfGI' }

let(:authenticator) do
Authentication::AuthnOidc::V2::DataObjects::Authenticator.new(
Expand All @@ -30,7 +31,7 @@

let(:current_client) do
instance_double(::Authentication::AuthnOidc::V2::Client).tap do |double|
allow(double).to receive(:callback).and_return(jwt)
allow(double).to receive(:callback).and_return([jwt, nil])
end
end

Expand All @@ -42,11 +43,13 @@
end

describe('#callback') do
context 'when a role_id matches the identity exist' do
context 'When a role_id matches the identity exist' do
let(:mapping) { "claim_mapping" }
it 'returns the role' do
expect(strategy.callback({ state: "foostate", code: "code" }))
.to eq("alice")
it 'returns only the role' do
role, token = strategy.callback({ state: "foostate", code: "code" })

expect(role).to eq("alice")
expect(token).to be_nil
end
end

Expand All @@ -65,5 +68,23 @@
.to raise_error(Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty)
end
end

context 'When refresh token flow is enabled' do
let(:current_client) do
instance_double(::Authentication::AuthnOidc::V2::Client).tap do |double|
allow(double).to receive(:callback).and_return([jwt, refresh_token])
end
end

context 'when a role_id matches the identity exist' do
let(:mapping) { "claim_mapping" }
it 'returns the role and its refresh token' do
role, token = strategy.callback({ state: "foostate", code: "code" })

expect(role).to eq("alice")
expect(token).to eq(refresh_token)
end
end
end
end
end
Loading

0 comments on commit e8ab69d

Please sign in to comment.