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 8ce2a4913515c5ac0b3a64629dd4b945002c1d9a
Author: Robert Newson <[email protected]>
AuthorDate: Mon Jun 29 13:27:32 2026 +0100

    DRAFT couch replication auth plugin for IBM IAM with refresh
---
 rel/overlay/etc/default.ini                        |   2 +-
 .../src/couch_replicator_auth_ibm_iam.erl          | 174 +++++++++++++++++++++
 2 files changed, 175 insertions(+), 1 deletion(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index aac8ff584..5f4a2dc91 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -808,7 +808,7 @@ partitioned||* = true
 ;   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..4de9ba98a
--- /dev/null
+++ b/src/couch_replicator/src/couch_replicator_auth_ibm_iam.erl
@@ -0,0 +1,174 @@
+% 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_SECS, 5 * 60).
+
+-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 = erlang:system_time(millisecond),
+    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, ExpirationSecs} = decode_iam_response(ResponseBody),
+            RefreshAtMs = refresh_at_ms(ExpirationSecs),
+            erlang:send_after(RefreshAtMs, self(), refresh, [{abs, true}]),
+            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.
+
+refresh_at_ms(ExpirationSecs) ->
+    erlang:convert_time_unit(ExpirationSecs - ?EARLY_REFRESH_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,
+        <<"expiration">> := ExpirationSecs
+    } = jiffy:decode(ResponseBody, [return_maps]),
+    {ok, binary_to_list(Token), ExpirationSecs}.
+
+token_url() ->
+    config:get("ibm_iam", "token_url", 
"https://iam.cloud.ibm.com/identity/token";).

Reply via email to