Skip to content

Commit

Permalink
Merge pull request #16180 from opf/feature/55643-extend-api-authentic…
Browse files Browse the repository at this point in the history
…ation-to-accept-jwt-issued-by-openid-provider-to-other-client

[#55643] Extend API authentication to accept JWT issued by OpenID pro…
  • Loading branch information
ba1ash authored Jul 24, 2024
2 parents 1ab3184 + 5c57582 commit 0c17dd7
Show file tree
Hide file tree
Showing 11 changed files with 383 additions and 80 deletions.
29 changes: 29 additions & 0 deletions config/initializers/jwt.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

JWT.configuration.strict_base64_decoding = true
13 changes: 10 additions & 3 deletions config/initializers/warden.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@
[:basic_auth_failure, namespace::BasicAuthFailure, "Basic"],
[:global_basic_auth, namespace::GlobalBasicAuth, "Basic"],
[:user_basic_auth, namespace::UserBasicAuth, "Basic"],
[:oauth, namespace::DoorkeeperOAuth, "OAuth"],
[:oauth, namespace::DoorkeeperOAuth, "Bearer"],
[:anonymous_fallback, namespace::AnonymousFallback, "Basic"],
[:jwt_oidc, namespace::JwtOidc, "Bearer"],
[:session, namespace::Session, "Session"]
]

strategies.each do |name, clazz, auth_scheme|
OpenProject::Authentication.add_strategy name, clazz, auth_scheme
OpenProject::Authentication.add_strategy(name, clazz, auth_scheme)
end

OpenProject::Authentication.update_strategies(OpenProject::Authentication::Scope::API_V3, { store: false }) do |_|
%i[global_basic_auth user_basic_auth basic_auth_failure oauth session anonymous_fallback]
%i[global_basic_auth
user_basic_auth
basic_auth_failure
oauth
jwt_oidc
session
anonymous_fallback]
end
end
6 changes: 4 additions & 2 deletions docker/dev/keycloak/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ services:
extra_hosts:
- "openproject.local:host-gateway"
environment:
- KC_DB_URL_HOST=db
- KC_DB=postgres
- KC_DB_USERNAME=keycloak
- KC_DB_PASSWORD=keycloak
- KC_DB_URL_DATABASE=jdbc:postgresql://db:5432/keycloak
- KC_DB_URL=jdbc:postgresql://db-keycloak:5432/keycloak
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
- KC_DB_SCHEMA=public
- KC_HOSTNAME=keycloak.local
- KC_FEATURES=token-exchange
- KC_TRANSACTION_XA_ENABLED=false
volumes:
- /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
- keycloak-data:/opt/keycloak/data/
Expand Down
11 changes: 6 additions & 5 deletions lib/api/root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@ class Root < ::API::RootAPI
error_representer ::API::V3::Errors::ErrorRepresenter, "application/hal+json; charset=utf-8"
authentication_scope OpenProject::Authentication::Scope::API_V3

OpenProject::Authentication.handle_failure(scope: API_V3) do |warden, _opts|
e = grape_error_for warden.env, self
OpenProject::Authentication.handle_failure(scope: API_V3) do |warden, opts|
e = grape_error_for(warden.env, self)
error_message = I18n.t("api_v3.errors.code_401_wrong_credentials")
api_error = ::API::Errors::Unauthenticated.new error_message
representer = ::API::V3::Errors::ErrorRepresenter.new api_error
api_error = ::API::Errors::Unauthenticated.new(error_message)
representer = ::API::V3::Errors::ErrorRepresenter.new(api_error)
status = opts[:message] == "insufficient_scope" ? 403 : 401

e.error! representer.to_json, 401, warden.headers
e.error!(representer.to_json, status, warden.headers)
end

version "v3", using: :path do
Expand Down
17 changes: 12 additions & 5 deletions lib_static/open_project/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -261,18 +261,25 @@ def scope_realm(scope = nil)
def response_header(
default_auth_scheme: self.default_auth_scheme,
scope: nil,
request_headers: {}
request_headers: {},
error: nil,
error_description: nil
)
scheme = pick_auth_scheme auth_schemes(scope), default_auth_scheme, request_headers

"#{scheme} realm=\"#{scope_realm(scope)}\""
scheme = pick_auth_scheme(auth_schemes(scope),
default_auth_scheme,
request_headers)

header = %{#{scheme} realm="#{scope_realm(scope)}"}
header << %{ error="#{error}"} if error
header << %{ error_description="#{error_description}"} if error && error_description
header
end

def auth_schemes(scope)
strategies = Array(Manager.scope_config(scope).strategies)

Manager.auth_schemes
.select { |_, info| scope.nil? or not (info.strategies & strategies).empty? }
.select { |_, info| scope.nil? or info.strategies.intersect?(strategies) }
.keys
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,65 +4,56 @@ module OpenProject
module Authentication
module Strategies
module Warden
##
# Allows testing authentication via doorkeeper OAuth2 token
#
class DoorkeeperOAuth < ::Warden::Strategies::Base
include FailWithHeader

# The strategy is supposed to handle bearer tokens that are not JWT.
# These tokens are issued by OpenProject
def valid?
::Doorkeeper::OAuth::Token
.from_request(decorated_request, *Doorkeeper.configuration.access_token_methods)
.present?
access_token = ::Doorkeeper::OAuth::Token
.from_request(decorated_request, *Doorkeeper.configuration.access_token_methods)

# No access token found, so invalid strategy.
return false if access_token.blank?

# We don't want JWT as our OAuth Bearer token
JWT.decode(access_token, nil, false)
false
rescue JWT::DecodeError
true
end

def authenticate!
token = ::Doorkeeper::OAuth::Token.authenticate(decorated_request,
*Doorkeeper.configuration.access_token_methods)
return fail!("invalid token") unless token&.acceptable?(scope)

if token.resource_owner_id.nil?
authenticate_client_credentials(token)
access_token = ::Doorkeeper::OAuth::Token.authenticate(decorated_request,
*Doorkeeper.configuration.access_token_methods)
return fail_with_header!(error: "invalid_token") if access_token.blank?
return fail_with_header!(error: "invalid_token") if access_token.expired? || access_token.revoked?
return fail_with_header!(error: "insufficient_scope") if !access_token.includes_scope?(scope)

if access_token.resource_owner_id.nil?
user_id = ::Doorkeeper::Application
.where(id: access_token.application_id)
.where.not(client_credentials_user_id: nil)
.pick(:client_credentials_user_id)
authenticate_user(user_id) if user_id
else
authenticate_user(token.resource_owner_id)
authenticate_user(access_token.resource_owner_id)
end
end

private

##
# We allow applications to designate a user to be used for client credentials.
# When using client credentials flow, find this user and try to authenticate
def authenticate_client_credentials(token)
if client_credential_user = find_credential_app_user(token.application_id)
authenticate_user client_credential_user
else
success! User.anonymous
end
end

##
# Find a credentials-enabled application with the given ID
# and return its allowed application user, if there is one.
# Avoid going through token.application.client_credentials_user_id for performance
# (this is going to be called on every request with CC flows!)
def find_credential_app_user(app_id)
::Doorkeeper::Application
.where(id: app_id)
.where.not(client_credentials_user_id: nil)
.pluck(:client_credentials_user_id)
.first
end

def authenticate_user(id)
user = User.find_by(id:)
if user
success!(user)
else
fail!("No such user")
fail_with_header!(error: "invalid_token")
end
end

def decorated_request
::Doorkeeper::Grape::AuthorizationDecorator.new(request)
@decorated_request ||= ::Doorkeeper::Grape::AuthorizationDecorator.new(request)
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module OpenProject
module Authentication
module Strategies
module Warden
module FailWithHeader
def fail_with_header!(error:, error_description: nil)
headers(
"WWW-Authenticate" => OpenProject::Authentication::WWWAuthenticate.response_header(
default_auth_scheme: "Bearer",
error:,
error_description:
)
)
fail!(error)
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
module OpenProject
module Authentication
module Strategies
module Warden
class JwtOidc < ::Warden::Strategies::Base
include FailWithHeader

SUPPORTED_ALG = %w[
RS256
RS384
RS512
].freeze

# The strategy is supposed to only handle JWT.
# These tokens are supposed to be issued by configured OIDC.
def valid?
@access_token = ::Doorkeeper::OAuth::Token.from_bearer_authorization(
::Doorkeeper::Grape::AuthorizationDecorator.new(request)
)
return false if @access_token.blank?

@unverified_payload, @unverified_header = JWT.decode(@access_token, nil, false)
@unverified_header.present? && @unverified_payload.present?
rescue JWT::DecodeError
false
end

def authenticate!
issuer = @unverified_payload["iss"]
provider = OpenProject::OpenIDConnect.providers.find { |p| p.configuration[:issuer] == issuer } if issuer.present?
if provider.blank?
return fail_with_header!(error: "invalid_token", error_description: "The access token issuer is unknown")
end

client_id = provider.configuration.fetch(:identifier)
alg = @unverified_header.fetch("alg")
if SUPPORTED_ALG.exclude?(alg)
return fail_with_header!(error: "invalid_token", error_description: "Token signature algorithm is not supported")
end

kid = @unverified_header.fetch("kid")
jwks_uri = provider.configuration[:jwks_uri]
key = JSON::JWK::Set::Fetcher.fetch(jwks_uri, kid:).to_key
begin
verified_payload, = JWT.decode(
@access_token,
key,
true,
{
algorithm: alg,
verify_iss: true,
verify_aud: true,
iss: issuer,
aud: client_id,
required_claims: ["sub", "iss", "aud"]
}
)
rescue JWT::ExpiredSignature
return fail_with_header!(error: "invalid_token", error_description: "The access token expired")
rescue JWT::ImmatureSignature
# happens when nbf time is less than current
return fail_with_header!(error: "invalid_token", error_description: "The access token is used too early")
rescue JWT::InvalidIssuerError
return fail_with_header!(error: "invalid_token", error_description: "The access token issuer is wrong")
rescue JWT::InvalidAudError
return fail_with_header!(error: "invalid_token", error_description: "The access token audience claim is wrong")
end

user = User.find_by(identity_url: "#{provider.name}:#{verified_payload['sub']}")
success!(user) if user
end
end
end
end
end
end
12 changes: 9 additions & 3 deletions modules/openid_connect/lib/open_project/openid_connect/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ class Engine < ::Rails::Engine

register_auth_providers do
OmniAuth::OpenIDConnect::Providers.configure custom_options: %i[
display_name? icon? sso? issuer?
check_session_iframe? end_session_endpoint?
limit_self_registration? use_graph_api?
display_name?
icon?
sso?
issuer?
check_session_iframe?
end_session_endpoint?
jwks_uri?
limit_self_registration?
use_graph_api?
]

strategy :openid_connect do
Expand Down
Loading

0 comments on commit 0c17dd7

Please sign in to comment.