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").
