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
 ================================
 

Reply via email to