This is an automated email from the ASF dual-hosted git repository. rnewson pushed a commit to branch ibm-iam-auth in repository https://gitbox.apache.org/repos/asf/couchdb.git
commit 24cbbb43a744ef4ae26ef470b4482c55a15a59c9 Author: Robert Newson <[email protected]> AuthorDate: Mon Jun 29 13:27:32 2026 +0100 couch replication auth plugin for IBM IAM with refresh --- rel/overlay/etc/default.ini | 5 +- .../src/couch_replicator_auth_ibm_iam.erl | 178 +++++++++++++++++++++ src/docs/src/config/replicator.rst | 5 +- src/docs/src/replication/replicator.rst | 22 +++ 4 files changed, 206 insertions(+), 4 deletions(-) diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini index aac8ff584..4a298fe28 100644 --- a/rel/overlay/etc/default.ini +++ b/rel/overlay/etc/default.ini @@ -803,12 +803,13 @@ partitioned||* = true ; particular endpoint (source or target). Normally couch_replicator_auth_noop ; would be used at the end of the list as a "catch-all". It doesn't do anything ; and effectively implements the previous behavior of using basic auth. -; There are currently two plugins available: +; There are currently three plugins available: ; couch_replicator_auth_session - use _session cookie authentication +; couch_replicator_auth_ibm_iam - use IBM's IAM service for authentication ; couch_replicator_auth_noop - use basic authentication (previous default) ; Currently, the new _session cookie authentication is tried first, before ; falling back to the old basic authentication default: -;auth_plugins = couch_replicator_auth_session,couch_replicator_auth_noop +auth_plugins = couch_replicator_auth_session,couch_replicator_auth_noop ; To restore the old behaviour, use the following value: ;auth_plugins = couch_replicator_auth_noop diff --git a/src/couch_replicator/src/couch_replicator_auth_ibm_iam.erl b/src/couch_replicator/src/couch_replicator_auth_ibm_iam.erl new file mode 100644 index 000000000..d291205c1 --- /dev/null +++ b/src/couch_replicator/src/couch_replicator_auth_ibm_iam.erl @@ -0,0 +1,178 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(couch_replicator_auth_ibm_iam). + +-behaviour(couch_replicator_auth). + +-export([ + initialize/1, + update_headers/2, + handle_response/3, + cleanup/1 +]). + +-export([token_acquirer/1]). + +-define(EARLY_REFRESH_MS, 5 * 60 * 1000). + +-include_lib("couch_replicator/include/couch_replicator_api_wrap.hrl"). +-include_lib("ibrowse/include/ibrowse.hrl"). + +-record(state, { + acquirer, + apikey :: binary(), + refresh_at_ms, + token :: list() +}). + +%% callbacks + +initialize(#httpdb{} = HttpDb) -> + case extract_apikey(HttpDb) of + ignore -> + ignore; + {ok, APIKey} -> + Acquirer = spawn_link(?MODULE, token_acquirer, [APIKey]), + {ok, HttpDb, #state{apikey = APIKey, acquirer = Acquirer}} + end. + +update_headers(#state{} = State0, Headers) when is_list(Headers) -> + State1 = get_current_token(State0), + {[{"Authorization", "Bearer " ++ State1#state.token} | Headers], State1}. + +get_current_token(#state{} = State) -> + NowMs = now_ms(), + if + State#state.token == undefined -> + State#state.acquirer ! {get_token, self()}; + NowMs >= State#state.refresh_at_ms -> + State#state.acquirer ! {get_token, self()} + end, + receive + {token, _RefreshAtMs, Token} when Token == undefined -> + timer:sleep(1000), + get_current_token(State); + {token, RefreshAtMs, Token} -> + State#state{refresh_at_ms = RefreshAtMs, token = Token} + end. + +handle_response(#state{} = State, StatusCode, Headers) when + is_integer(StatusCode), is_list(Headers) +-> + {continue, State}. + +cleanup(#state{} = State) -> + unlink(State#state.acquirer), + exit(State#state.acquirer, kill), + ok. + +%% private functions + +-record(acquirer, { + apikey, + ibrowse_req_id, + refresh_at_ms, + token +}). + +token_acquirer(APIKey) when is_binary(APIKey) -> + case send_req(APIKey) of + {error, Reason} -> + exit(Reason); + {ibrowse_req_id, NewReqId} -> + token_acquirer_loop(#acquirer{apikey = APIKey, ibrowse_req_id = NewReqId}) + end. + +token_acquirer_loop(#acquirer{ibrowse_req_id = ReqId} = Acquirer) -> + receive + refresh -> + case send_req(Acquirer#acquirer.apikey) of + {error, Reason} -> + exit(Reason); + {ibrowse_req_id, NewReqId} -> + token_acquirer_loop(Acquirer#acquirer{ibrowse_req_id = NewReqId}) + end; + {get_token, From} -> + From ! {token, Acquirer#acquirer.refresh_at_ms, Acquirer#acquirer.token}, + token_acquirer_loop(Acquirer); + {ibrowse_async_headers, ReqId, "200", _ResponseHeaders} -> + token_acquirer_loop(Acquirer); + {ibrowse_async_headers, ReqId, "401", _ResponseHeaders} -> + exit(unauthorized); + {ibrowse_async_headers, ReqId, "403", _ResponseHeaders} -> + exit(forbidden); + {ibrowse_async_headers, ReqId, "500", _ResponseHeaders} -> + erlang:send_after(10000, self(), refresh), + token_acquirer_loop(Acquirer#acquirer{ibrowse_req_id = undefined}); + {ibrowse_async_headers, ReqId, StatusCode, _ResponseHeaders} -> + exit(StatusCode); + {ibrowse_async_response, ReqId, ResponseBody} -> + {ok, Token, ExpiresInSecs} = decode_iam_response(ResponseBody), + RefreshAtMs = now_ms() + sec_to_ms(ExpiresInSecs) - ?EARLY_REFRESH_MS, + RefreshInMs = sec_to_ms(ExpiresInSecs) - ?EARLY_REFRESH_MS, + erlang:send_after(RefreshInMs, self(), refresh), + token_acquirer_loop(Acquirer#acquirer{ + ibrowse_req_id = undefined, refresh_at_ms = RefreshAtMs, token = Token + }); + {ibrowse_async_response_end, ReqId} -> + token_acquirer_loop(Acquirer#acquirer{ibrowse_req_id = undefined}); + {ibrowse_async_response_timeout, ReqId} -> + erlang:send_after(10000, self(), refresh), + token_acquirer_loop(Acquirer#acquirer{ibrowse_req_id = undefined}); + %% consume ibrowse responses for stale requests + {ibrowse_async_headers, _OtherReqId, _StatusCode, _ResponseHeaders} -> + token_acquirer_loop(Acquirer); + {ibrowse_async_response, _OtherReqId} -> + token_acquirer_loop(Acquirer); + {ibrowse_async_response_end, _OtherReqId} -> + token_acquirer_loop(Acquirer); + {ibrowse_async_response_timeout, _OtherReqId} -> + token_acquirer_loop(Acquirer) + end. + +now_ms() -> + os:system_time(millisecond). + +sec_to_ms(Secs) -> + erlang:convert_time_unit(Secs, second, millisecond). + +extract_apikey(#httpdb{auth_props = AuthProps}) -> + case proplists:get_value(<<"ibm">>, AuthProps) of + undefined -> + ignore; + {IBMProps} -> + case proplists:get_value(<<"apikey">>, IBMProps) of + undefined -> + {error, missing_apikey}; + ApiKey -> + {ok, ApiKey} + end + end. + +send_req(APIKey) -> + Body = + <<"grant_type=urn:ibm:params:oauth:grant-type:apikey&response_type=cloud_iam&apikey=", + APIKey/binary>>, + Options = [{stream_to, self()}, {ssl_options, [{cacerts, public_key:cacerts_get()}]}], + ibrowse:send_req(token_url(), [], post, Body, Options). + +decode_iam_response(ResponseBody) -> + #{ + <<"token_type">> := <<"Bearer">>, + <<"access_token">> := Token, + <<"expires_in">> := ExpiresInSecs + } = jiffy:decode(ResponseBody, [return_maps]), + {ok, binary_to_list(Token), ExpiresInSecs}. + +token_url() -> + config:get("ibm_iam", "token_url", "https://iam.cloud.ibm.com/identity/token"). diff --git a/src/docs/src/config/replicator.rst b/src/docs/src/config/replicator.rst index ceb3eebba..fd6b5d9a4 100644 --- a/src/docs/src/config/replicator.rst +++ b/src/docs/src/config/replicator.rst @@ -370,9 +370,10 @@ Replicator Database Configuration List of replicator client authentication plugins. Plugins will be tried in order and the first to initialize successfully will - be used. By default there are two plugins available: + be used. By default there are three plugins available: `couch_replicator_auth_session` implementing session (cookie) - authentication, and `couch_replicator_auth_noop` implementing basic + authentication, `couch_replicator_auth_ibm_iam` integrating + with IBM's IAM service and `couch_replicator_auth_noop` implementing basic authentication. For backwards compatibility, the no-op plugin should be used at the end of the plugin list:: diff --git a/src/docs/src/replication/replicator.rst b/src/docs/src/replication/replicator.rst index 3d8b6bf7b..a3c0c8835 100644 --- a/src/docs/src/replication/replicator.rst +++ b/src/docs/src/replication/replicator.rst @@ -809,6 +809,28 @@ they are used. If they are not, then URL userinfo is checked. If credentials are found there, then those credentials are used, otherwise basic auth header is used. +Using an IBM IAM apikey +======================= + +If you've enabled the optional `couch_replicator_auth_ibm_auth` +replicator authentication plugin you can specify an IBM IAM apikey in +your source or target as follows; + + .. code-block:: javascript + + { + "target": { + "url": "http://someurl.com/mydb", + "auth": { + "ibm": { + "apikey": "$apikey" + } + } + }, + ... + } + + Replicate Winning Revisions Only ================================
