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

Refactor OIDC client to include refresh token exchange #2667

Merged
merged 1 commit into from
Oct 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 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,12 +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 @@ -77,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)
expected_nonce = nonce
if refresh && decoded_id_token.raw_attributes['nonce'].nil?
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 @@ -93,7 +126,6 @@ def callback(code:, nonce:, code_verifier:)
raise Errors::Authentication::AuthnOidc::TokenVerificationFailed,
e.message
end
[decoded_id_token, refresh_token]
end

def discovery_information(invalidate: false)
Expand Down
2 changes: 1 addition & 1 deletion app/domain/authentication/authn_oidc/v2/strategy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def callback(args)
end
end

jwt, refresh_token = @client.callback(
jwt, refresh_token = @client.get_token_with_code(
code: args[:code],
nonce: args[:nonce],
code_verifier: args[:code_verifier]
Expand Down
84 changes: 76 additions & 8 deletions spec/app/domain/authentication/authn-oidc/v2/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@
end
end

describe '.callback' do
describe '.get_token_with_code' do
context 'when credentials are valid' do
it 'returns a valid JWT token', vcr: 'authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials' do
# Because JWT tokens have an expiration timeframe, we need to hold
# time constant after caching the request.
travel_to(Time.parse("2022-09-30 17:02:17 +0000")) do
id_token, refresh_token = client.callback(
id_token, refresh_token = client.get_token_with_code(
code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw',
code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d',
nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d'
Expand All @@ -60,7 +60,7 @@
it 'raises an error', vcr: 'authenticators/authn-oidc/v2/client_callback-invalid_code_verifier' do
travel_to(Time.parse("2022-10-17 17:23:30 +0000")) do
expect do
client.callback(
client.get_token_with_code(
code: 'GV48_SF4a19ghvBhVbbSG3Lr8BuFl8PhWVPZSbokV2o',
code_verifier: 'bad-code-verifier',
nonce: '3e6bd5235e4692b37ca1f04cb01b6e0cb177aa20dcef19e89f'
Expand All @@ -77,7 +77,7 @@
it 'raises an error', vcr: 'authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials' do
travel_to(Time.parse("2022-09-30 17:02:17 +0000")) do
expect do
client.callback(
client.get_token_with_code(
code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw',
code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d',
nonce: 'bad-nonce'
Expand All @@ -94,7 +94,7 @@
it 'raises an error', vcr: 'authenticators/authn-oidc/v2/client_callback-valid_oidc_credentials' do
travel_to(Time.parse("2022-10-01 17:02:17 +0000")) do
expect do
client.callback(
client.get_token_with_code(
code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw',
code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d',
nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d'
Expand All @@ -110,7 +110,7 @@
context 'when code has previously been used' do
it 'raise an exception', vcr: 'authenticators/authn-oidc/v2/client_callback-used_code-valid_oidc_credentials' do
expect do
client.callback(
client.get_token_with_code(
code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw',
code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d',
nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d'
Expand All @@ -125,7 +125,7 @@
context 'when code has expired', vcr: 'authenticators/authn-oidc/v2/client_callback-expired_code-valid_oidc_credentials' do
it 'raise an exception' do
expect do
client.callback(
client.get_token_with_code(
code: 'SNSPeiQJ0-D6nUHTg-Ht9ZoDxIaaWBB80pnYuXY2VxU',
code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d',
nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d'
Expand All @@ -150,7 +150,7 @@
# Because JWT tokens have an expiration timeframe, we need to hold
# time constant after caching the request.
travel_to(Time.parse("2022-09-30 17:02:17 +0000")) do
id_token, refresh_token = client.callback(
id_token, refresh_token = client.get_token_with_code(
code: '-QGREc_SONbbJIKdbpyYudA13c9PZlgqdxowkf45LOw',
code_verifier: 'c1de7f1251849accd99d4839d79a637561b1181b909ed7dc1d',
nonce: '7efcbba36a9b96fdb5285a159665c3d382abd8b6b3288fcc8d'
Expand All @@ -169,6 +169,74 @@
end
end

describe '.get_token_with_refresh_token' do
# Use different Okta authorization server with refresh tokens enabled.
# At some point, all these test cases should point to a single Okta server,
# with PKCE and refresh tokens both enabled.
let(:authenticator) do
Authentication::AuthnOidc::V2::DataObjects::Authenticator.new(
**authn_config.merge!({
:provider_uri => 'https://dev-56357110.okta.com/oauth2/default',
:client_id => '0oa6ccivzf3nEeiGt5d7',
:client_secret => 'YnAukUECEAtsWSWCHPzi1coiZZeOhdvQOSnri4Kz',
:provider_scope => 'offline_access'
})
)
end

context 'when refresh token is valid' do
context 'with refresh token rotation disabled' do
it 'returns a valid JWT token', vcr: 'authenticators/authn-oidc/v2/client_refresh-valid_token' do
travel_to(Time.parse("2022-10-19 17:02:17 +0000")) do
id_token, refresh_token = client.get_token_with_refresh_token(
refresh_token: 'a8VLPRtcOS5-IFYXkZYzZbrIhSJq6trFXxYJyKbaUng',
nonce: 'some-nonce'
)
expect(id_token).to be_a_kind_of(OpenIDConnect::ResponseObject::IdToken)
expect(id_token.raw_attributes['nonce']).to be_nil
expect(id_token.raw_attributes['preferred_username']).to eq('[email protected]')
expect(id_token.aud).to eq('0oa6ccivzf3nEeiGt5d7')

expect(refresh_token).to be_nil
end
end
end

context 'with refresh token rotation enabled' do
it 'returns a valid JWT token and refresh token', vcr: 'authenticators/authn-oidc/v2/client_refresh-valid_token_with_rotation' do
travel_to(Time.parse("2022-10-19 17:02:17 +0000")) do
id_token, refresh_token = client.get_token_with_refresh_token(
refresh_token: 'a8VLPRtcOS5-IFYXkZYzZbrIhSJq6trFXxYJyKbaUng',
nonce: 'some-nonce'
)
expect(id_token).to be_a_kind_of(OpenIDConnect::ResponseObject::IdToken)
expect(id_token.raw_attributes['nonce']).to be_nil
expect(id_token.raw_attributes['preferred_username']).to eq('[email protected]')
expect(id_token.aud).to eq('0oa6ccivzf3nEeiGt5d7')

expect(refresh_token).to eq('dyJXfWUg1Xjt4KP7IQ7qcHUVNtKNWmmtOu9qNScjkN8')
end
end
end
end

context 'when refresh token is invalid or expired' do
it 'raises an error', vcr: 'authenticators/authn-oidc/v2/client_refresh-invalid_token' do
travel_to(Time.parse("2022-10-19 17:02:17 +0000")) do
expect do
client.get_token_with_refresh_token(
refresh_token: 'a8VLPRtcOS5-IFYXkZYzZbrIhSJq6trFXxYJyKbaUng',
nonce: 'some-nonce'
)
end.to raise_error(
Errors::Authentication::AuthnOidc::TokenRetrievalFailed,
"CONJ00133E Access Token retrieval failure: 'Refresh token is invalid or has expired'"
)
end
end
end
end

describe '.oidc_client' do
context 'when credentials are valid' do
it 'returns a valid oidc client', vcr: 'authenticators/authn-oidc/v2/client_initialization' do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

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

Expand Down Expand Up @@ -83,7 +83,7 @@
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])
allow(double).to receive(:get_token_with_code).and_return([jwt, refresh_token])
end
end

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading