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

Add token revocation and session management to AuthnOIDC #2674

Closed
wants to merge 7 commits into from

Conversation

john-odonnell
Copy link
Contributor

Desired Outcome

If we plan on distributing long-lived OIDC refresh tokens, we need more token management features. We should at least include token revocation for dangling tokens, but should also look into session termination.

Implemented Changes

  • Refactors the Authentication::AuthnOidc::V2::Strategy class into ::Strategies::AuthzCode and ::Strategies::RefreshToken. This will hopefully make it easier to:
    1. Implement an end-session strategy
    2. Refactor our current OIDC ID token authentication strategy
  • Update openid_connect gem and Keycloak docker-compose service used dev and ci environments. Both were missing token revocation features.
  • Update the OIDC client class:
    1. Add .revoke method, for invalidating a given refresh token
    2. Add .end_session method, for revoking dangling tokens and returning a URI to the configured OIDC provider's session management API
    3. If applicable, ensure that rotated refresh tokens are revoked after use
  • Adds an API endpoint, /authn-oidc/:account/:service_id/logout, which employ a new Logout handler and strategy.

Open Questions

  • Adds a Authentication::Handler::LogoutHandler class. This was an attempt to give the AuthenticateController a path to objects initialized in the AuthenticationHandler class without legitimately authenticating and issuing a token. Wondering if there is a better solution to be found here.

Connected Issue/Story

Resolves #[relevant GitHub issue(s), e.g. 76]

CyberArk internal issue link: insert issue ID

Definition of Done

At least 1 todo must be completed in the sections below for the PR to be
merged.

Changelog

  • The CHANGELOG has been updated, or
  • This PR does not include user-facing changes and doesn't require a
    CHANGELOG update

Test coverage

  • This PR includes new unit and integration tests to go with the code
    changes, or
  • The changes in this PR do not require tests

Documentation

  • Docs (e.g. READMEs) were updated in this PR
  • A follow-up issue to update official docs has been filed here: insert issue ID
  • This PR does not require updating any documentation

Behavior

  • This PR changes product behavior and has been reviewed by a PO, or
  • These changes are part of a larger initiative that will be reviewed later, or
  • No behavior was changed with this PR

Security

  • Security architect has reviewed the changes in this PR,
  • These changes are part of a larger initiative with a separate security review, or
  • There are no security aspects to these changes


render(json: { oidc_logout_uri: logout_uri })
end

def authenticate_oidc
Copy link
Contributor

Choose a reason for hiding this comment

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

This is the original "OIDC" functionality which is really just a glorified JWT validation specific to OIDC. If you're going to hijack the POST route, I'd recommend splitting this up a bit differently, something like:

  def authenticate_oidc
    # Parse JSON request body
    # If refresh token is present, do refresh token workflow
    # Else, do this:
      params[:authenticator] = "authn-oidc"
      input = Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new.(
        authenticator_input: authenticator_input
      )
      # We don't audit success here as the authentication process is not done
    rescue => e
      # At this point authenticator_input.username is always empty (e.g. cucumber:user:USERNAME_MISSING)
      log_audit_failure(
        authn_params: authenticator_input,
        audit_event_class: Audit::Event::Authn::Authenticate,
        error: e
      )
      handle_authentication_error(e)
    else
      authenticate(input)
    end

Authentication::AuthnOidc::V2::Strategies::AuthzCode.new(
authenticator: authenticator
).call(params.to_hash.symbolize_keys)
end
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd actually recommend pushing the logic for choosing a strategy up into the Authentication::AuthnOidc::V2::Strategy class. This will allow you to keep details of a particular strategy out of the controller (which ultimately should be super generic). Something like:

module Authentication
  module AuthnOidc
    module V2
      class Strategy
        def callback(args)
          if args.key?(:refresh_token)
            # run refresh token workflow
          elsif(args.key?(:code)
            # run code validation workflow
          end
          ...
        end
      end
    end
  end
end

params.permit!

if params[:code] || params[:refresh_token]
namespace = 'Authentication::AuthnOidc::V2::Strategies'
Copy link
Contributor

Choose a reason for hiding this comment

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

These should be pushed up into the Strategy as described above to keep the OIDC abstraction from bubbling up.

end

def end_session_uri(id_token:, state:, redirect_uri:)
query = URI.encode_www_form(
Copy link
Contributor

Choose a reason for hiding this comment

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

URI should be injected to simplify testing.

@@ -135,7 +171,7 @@ def discovery_information(invalidate: false)
skip_nil: true
) do
@discovery_configuration.discover!(@authenticator.provider_uri)
rescue HTTPClient::ConnectTimeoutError, Errno::ETIMEDOUT => e
rescue Faraday::TimeoutError, Errno::ETIMEDOUT => e
Copy link
Contributor

Choose a reason for hiding this comment

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

Did the OpenID-Connect gem move from HTTPClient to Faraday?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, only recently in v2.0.0+

@logger = logger
end
module Strategies
module Common
Copy link
Contributor

Choose a reason for hiding this comment

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

Please NEVER use modules to inject methods into downstream objects, particularly the initializer. Modules make it nearly impossible to reason about where a classes methods come from.

Instead either use inheritance (probably the correct solution for this section), or create a generic, stateless object "Utility" object with common methods. Utility objects should follow adhere to functional programming best practices (stateless, no mutation, etc.).

- Instead of returning arrays, return hashes with OIDC tokens. Consumers can be
  ignorant of position in favor of querying the returned hash with descriptive
  keys.
- Refactor public-facing functions to reduce repitition.
- Create `Strategies` module, home to classes defining the relationship between
  the generalized OIDC V2 strategy, and the underlying OIDC client. This new
  pattern will be easy to extend to support additional flows.
- Instead of passing arrays of data between `Client`, `Strategy` and
  `AuthenticationHandler` classes, pass hashes with descriptive keys.
@john-odonnell john-odonnell marked this pull request as ready for review December 7, 2022 17:54
@john-odonnell john-odonnell requested a review from a team as a code owner December 7, 2022 17:54
@john-odonnell
Copy link
Contributor Author

Closing. Refresh token support was abandoned in favor of a configurable Conjur access token TTL - see #2683.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants