Allow to use API Tokens as Bearer tokens

We generate those tokens with a prefix, so that we
can decide by looking at a token, whether it's an API Token
or a different kind of token, so that we can decide which
code path to choose for validating the token.

The usage of access tokens as Bearer token has the usability advantage,
that you can paste them as plaintext into tools that expect you
to specify the token as a header.

Also the Basic auth approach for our old tokens usually rather caused
issues, such as browsers prompting for credentials in surprising situations.
If we were to deprecate basic authentication one day, this change today could've
been the first step towards that.
This commit is contained in:
Jan Sandbrink
2026-01-30 10:17:25 +01:00
parent ec24cfcba9
commit 4d305df714
14 changed files with 243 additions and 34 deletions
+1
View File
@@ -30,5 +30,6 @@
module Token
class API < Named
prefix :opapi
end
end
+2
View File
@@ -32,6 +32,8 @@ module Token
class AutoLogin < HashedToken
include ExpirableToken
prefix :opal
has_many :autologin_session_links,
class_name: "Sessions::AutologinSessionLink",
foreign_key: "token_id",
+2
View File
@@ -30,6 +30,8 @@
module Token
class Backup < HashedToken
prefix :opbk
def ready?
return false if created_at.nil?
+29 -14
View File
@@ -64,22 +64,37 @@ module Token
# Delete previous token of this type upon save
before_save :delete_previous_token
##
# Find a token from the token value
def self.find_by_plaintext_value(input)
find_by(value: input)
end
class << self
##
# A DSL method allowing to define a prefix for all generated tokens, making it possible to recognize
# the purpose of a token by looking at the token value.
#
# class MyToken < HashedToken
# prefix :my
# end
def prefix(value = nil)
@prefix = value.to_s if value
##
# Find tokens for the given user
def self.for_user(user)
where(user:)
end
@prefix
end
##
# Generate a random hex token value
def self.generate_token_value
SecureRandom.hex(32)
##
# Find a token from the token value
def find_by_plaintext_value(input)
find_by(value: input)
end
##
# Find tokens for the given user
def for_user(user)
where(user:)
end
##
# Generate a random hex token value
def generate_token_value
[prefix, SecureRandom.hex(32)].compact.join("-")
end
end
##
+1 -1
View File
@@ -53,7 +53,7 @@ module Token
class << self
def create_and_return_value(user)
create(user:).plain_value
create!(user:).plain_value
end
##
+2
View File
@@ -30,6 +30,8 @@
module Token
class ICal < HashedToken
prefix :opical
# restrict the usage of one ical token to one query (calendar)
has_one :ical_token_query_assignment, required: true, dependent: :destroy, foreign_key: :ical_token_id,
class_name: "ICalTokenQueryAssignment", inverse_of: :ical_token
+1 -1
View File
@@ -384,7 +384,7 @@ default:
# password: admin
# By default, the APIv3 allows authentication through basic auth.
# Uncomment the following line to restrict APIv3 access to session.
# Uncomment the following line to prevent APIv3 access using Basic auth.
# apiv3_enable_basic_auth: false
# You can configure where users should be sent after the login
+3 -1
View File
@@ -34,6 +34,7 @@ strategies = [
[:basic_auth_failure, namespace::BasicAuthFailure, "Basic"],
[:global_basic_auth, namespace::GlobalBasicAuth, "Basic"],
[:user_basic_auth, namespace::UserBasicAuth, "Basic"],
[:user_api_token, namespace::UserAPIToken, "Bearer"],
[:oauth, namespace::DoorkeeperOAuth, "Bearer"],
[:anonymous_fallback, namespace::AnonymousFallback, "Basic"],
[:jwt_oidc, namespace::JwtOidc, "Bearer"],
@@ -48,6 +49,7 @@ OpenProject::Authentication.update_strategies(OpenProject::Authentication::Scope
%i[global_basic_auth
user_basic_auth
basic_auth_failure
user_api_token
oauth
jwt_oidc
session
@@ -59,7 +61,7 @@ OpenProject::Authentication.update_strategies(OpenProject::Authentication::Scope
end
OpenProject::Authentication.update_strategies(OpenProject::Authentication::Scope::MCP_SCOPE, { store: false }) do |_|
%i[oauth jwt_oidc user_basic_auth basic_auth_failure]
%i[user_api_token oauth jwt_oidc user_basic_auth basic_auth_failure]
end
Rails.application.configure do |app|
+30 -10
View File
@@ -49,7 +49,15 @@ info:
## Authentication
The API supports the following authentication schemes: OAuth2, session based authentication, and basic auth.
The API supports the following authentication schemes:
* Session-based authentication
* API tokens
* passed as Bearer token
* passed via Basic auth
* OAuth 2.0
* using built-in authorization server
* using an external authorization server (RFC 9068)
Depending on the settings of the OpenProject instance many resources can be accessed without being authenticated.
In case the instance requires authentication on all requests the client will receive an **HTTP 401** status code
@@ -57,26 +65,38 @@ info:
Otherwise unauthenticated clients have all the permissions of the anonymous user.
### Session-based Authentication
### Session-based authentication
This means you have to login to OpenProject via the Web-Interface to be authenticated in the API.
This method is well-suited for clients acting within the browser, like the Angular-Client built into OpenProject.
In this case, you always need to pass the HTTP header `X-Requested-With "XMLHttpRequest"` for authentication.
### API Key through Basic Auth
### API token as bearer token
Users can authenticate towards the API v3 using basic auth with the user name `apikey` (NOT your login) and the API key as the password.
Users can find their API key on their account page.
Users can authenticate towards the API v3 using an API token as a bearer token.
Example:
For example:
```shell
API_KEY=2519132cdf62dcf5a66fd96394672079f9e9cad1
API_KEY=opapi-2519132cdf62dcf5a66fd96394672079f9e9cad1
curl -H "Authorization: Bearer $API_KEY" https://community.openproject.org/api/v3/users/42
```
Users can generate API tokens on their account page.
### API token through Basic Auth
API tokens can also be used with basic auth, using the user name `apikey` (NOT your login) and the API token as the password.
For example:
```shell
API_KEY=opapi-2519132cdf62dcf5a66fd96394672079f9e9cad1
curl -u apikey:$API_KEY https://community.openproject.org/api/v3/users/42
```
### OAuth2.0 authentication
### OAuth 2.0 authentication
OpenProject allows authentication and authorization with OAuth2 with *Authorization code flow*, as well as *Client credentials* operation modes.
@@ -91,7 +111,7 @@ info:
- [Client credentials](https://oauth.net/2/grant-types/client-credentials/) - Requires an application to be bound to an impersonating user for non-public access
### OIDC provider generated JWT as a Bearer token
### OAuth 2.0 using an external authorization server
There is a possibility to use JSON Web Tokens (JWT) generated by an OIDC provider configured in OpenProject as a bearer token to do authenticated requests against the API.
The following requirements must be met:
@@ -103,7 +123,7 @@ info:
- JWT **scope** claim must include a valid scope to access the desired API (e.g. `api_v3` for APIv3)
- JWT must be actual (neither expired or too early to be used)
- JWT must be passed in Authorization header like: `Authorization: Bearer {jwt}`
- User from **sub** claim must be logged in OpenProject before otherwise it will be not authenticated
- User from **sub** claim must be linked to OpenProject before (e.g. by logging in), otherwise it will be not authenticated
In more general terms, OpenProject should be compliant to [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) when validating access tokens.
+5 -5
View File
@@ -10,7 +10,7 @@ release_date: 2024-08-14
Release date: 2024-08-14
We released [OpenProject 14.4.0](https://community.openproject.org/versions/2063). The release contains several bug fixes and we recommend updating to the newest version.
We released [OpenProject 14.4.0](https://community.openproject.org/versions/2063). The release contains several bug fixes and we recommend updating to the newest version.
In these Release Notes, we will give an overview of important technical updates as well as important feature changes. At the end, you will find a complete list of all changes and bug fixes.
@@ -22,7 +22,7 @@ OpenProject 14.4 introduces a new feature that allows OpenID clients, such as Ne
With this feature, the OpenProject API will validate access tokens issued by the OpenID provider (Keycloak) by checking the token's signature and authenticating the user using the sub claim value. This integration ensures secure and efficient API authentication for OpenID clients.
For more details, take a look at our [API documentation](../../../api/introduction/#oidc-provider-generated-jwt-as-a-bearer-token).
For more details, take a look at our [API documentation](../../../api/introduction/#oauth-20-using-an-external-authorization-server).
### Improve error messages and logs of automatically managed project folders synchronization services/jobs
@@ -38,7 +38,7 @@ For more details, see this [work package](https://community.openproject.org/wp/5
### Personal settings: Dark mode
Dark mode for OpenProject is finally here! In the '[My account](../../../user-guide/account-settings/#look-and-feel)' section under 'Interface', there is an **option labeled 'Mode' where users can now select 'Dark (Beta).'** as an alternative to the light mode. When the dark mode is selected, the change applies only to that user, not to the entire instance.
Dark mode for OpenProject is finally here! In the '[My account](../../../user-guide/account-settings/#look-and-feel)' section under 'Interface', there is an **option labeled 'Mode' where users can now select 'Dark (Beta).'** as an alternative to the light mode. When the dark mode is selected, the change applies only to that user, not to the entire instance.
![News setting for dark mode in OpenProject, displayed in dark mode](openproject-14-4-dark-mode.png)
@@ -222,12 +222,12 @@ Clicking on the "Details" link will take the user to the diff view, which is als
## Contributions
A very special thank you goes to the City of Cologne again for sponsoring features on project attributes and project lists.
A very special thank you goes to the City of Cologne again for sponsoring features on project attributes and project lists.
Also a big thanks to our Community members for reporting bugs and helping us identify and provide fixes.
Special thanks for reporting and finding bugs go to Johan Bouduin, Sven Kunze and Marcel Carvalho.
Last but not least, we are very grateful for our very engaged translation contributors on Crowdin, who translated quite a few OpenProject strings! This release we would like to highlight the three following users:
Last but not least, we are very grateful for our very engaged translation contributors on Crowdin, who translated quite a few OpenProject strings! This release we would like to highlight the three following users:
- [Jeff Li](https://crowdin.com/profile/jeff_li) for translations to Chinese Simplified,
- [Adam Siemienski](https://crowdin.com/profile/siemienas) for translations to Polish,
@@ -0,0 +1,83 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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.
#++
module OpenProject
module Authentication
module Strategies
module Warden
##
# Allows users to authenticate using their API key as a Bearer token.
# Note that in order for a user to be able to generate one
# `Setting.rest_api_enabled` has to be `1`.
class UserAPIToken < ::Warden::Strategies::Base
include FailWithHeader
def valid?
return false unless Setting.rest_api_enabled?
@access_token = ::Doorkeeper::OAuth::Token.from_bearer_authorization(
::Doorkeeper::Grape::AuthorizationDecorator.new(request)
)
return false if @access_token.blank?
@access_token.start_with?(::Token::API.prefix)
end
def authenticate!
token = ::Token::API.find_by_plaintext_value(@access_token) # rubocop:disable Rails/DynamicFindBy
return fail_with_header!(error: "invalid_token") if token.nil?
authentication_result(token.user)
end
private
def authentication_result(user)
if user.nil?
return fail_with_header!(
error: "invalid_token",
error_description: "The user identified by the token is not known"
)
end
if user.active?
success!(user)
else
fail_with_header!(
error: "invalid_token",
error_description: "The user account is locked"
)
end
end
end
end
end
end
end
+14
View File
@@ -47,4 +47,18 @@ RSpec.describe Token::Base do
expect(described_class.exists?(subject.id)).to be false
expect(described_class.exists?(t2.id)).to be true
end
context "when defining a prefix" do
subject { subclass.new(user:) }
let(:subclass) { Class.new(described_class) { prefix :test } }
it "has a plaintext value starting with the prefix" do
expect(subject.value).to start_with("test-")
end
it "has the regular token value after the prefix" do
expect(subject.value.delete_prefix("test-").length).to eq(64)
end
end
end
@@ -225,6 +225,74 @@ RSpec.describe "API V3 Authentication" do
end
end
describe "API Key as Bearer token" do
let(:token) { create(:api_token, user:) }
let(:bearer_token) { token.plain_value }
let(:expected_message) { "You did not provide the correct credentials." }
before do
user
header "Authorization", "Bearer #{bearer_token}"
get resource
end
context "with a valid access token" do
it "authenticates successfully" do
expect(last_response).to have_http_status :ok
end
end
context "with an invalid access token" do
let(:bearer_token) { "opapi-1337" }
let(:expected_www_auth_header) do
%{Bearer realm="OpenProject API", #{resource_metadata}, scope="api_v3", error="invalid_token"}
end
it "returns unauthorized" do
expect(last_response).to have_http_status :unauthorized
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
expect(JSON.parse(last_response.body)).to eq(error_response_body)
end
end
context "when the token's user can't be found" do
let(:expected_www_auth_header) do
%{Bearer realm="OpenProject API", #{resource_metadata}, scope="api_v3", error="invalid_token"}
end
around do |ex|
# create the token before deleting the user; right now it especially works, because a foreign key constraint prevents
# tokens without users
token
user.destroy!
ex.run
end
it "returns unauthorized" do
expect(last_response).to have_http_status :unauthorized
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
expect(JSON.parse(last_response.body)).to eq(error_response_body)
end
end
context "when the token's user is locked" do
let(:user) { create(:user, :locked) }
let(:expected_www_auth_header) do
"Bearer realm=\"OpenProject API\", #{resource_metadata}, scope=\"api_v3\", error=\"invalid_token\", " \
"error_description=\"#{expected_error_description}\""
end
let(:expected_error_description) { "The user account is locked" }
it "returns unauthorized" do
expect(last_response).to have_http_status :unauthorized
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
expect(JSON.parse(last_response.body)).to eq(error_response_body)
end
end
end
describe "basic auth" do
let(:expected_message) { "You need to be authenticated to access this resource." }
+2 -2
View File
@@ -81,9 +81,9 @@ RSpec.describe "MCP tools/list", with_flag: { mcp_server: true } do
it_behaves_like "MCP unauthenticated response"
end
context "when passing an API key via Basic auth" do
context "when passing an API token via Bearer authentication" do
subject do
header "Authorization", "Basic #{Base64.encode64("apikey:#{apikey.plain_value}")}"
header "Authorization", "Bearer #{apikey.plain_value}"
header "Content-Type", "application/json"
post "/mcp", request_body.to_json
end