nickva commented on code in PR #6069:
URL: https://github.com/apache/couchdb/pull/6069#discussion_r3916938231


##########
src/couch_replicator/src/couch_replicator_auth_ibm.erl:
##########
@@ -0,0 +1,750 @@
+% 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.
+
+% This module allows a replication source or target to use an IAM api key for 
authentication.
+%
+% Features;
+%
+% Automatic refreshing of time-limited token before expiration
+% Deduplication - only one token will be acquired for each distinct IAM api key
+%
+% Implementation details
+%
+% As api keys are sensitive, the only copy of api keys is held in a private 
ETS table
+% owned by this module's gen_server. An opaque reference is returned to 
clients (this is
+% a message authentication code where the key is a non-persisted value 
generated by the
+% gen_server)
+
+-module(couch_replicator_auth_ibm).
+
+-behaviour(couch_replicator_auth).
+-behaviour(gen_server).
+-behaviour(config_listener).
+
+-export([
+    sup_initialize/0,
+    sup_cleanup/1,
+    initialize/1,
+    update_headers/2,
+    handle_response/3,
+    cleanup/1
+]).
+
+%% gen_server callbacks
+-export([
+    init/1,
+    handle_call/3,
+    handle_cast/2,
+    handle_info/2,
+    terminate/2
+]).
+
+% config_listener callbacks
+-export([
+    handle_config_change/5,
+    handle_config_terminate/3
+]).
+
+-include_lib("couch_replicator/include/couch_replicator_api_wrap.hrl").
+-compile({no_auto_import, [now/0]}).
+
+-define(EARLY_REFRESH_MS, 300_000).
+-define(JITTER_MS, 60_000).
+-define(MIN_REFRESH_MS, 10_000).
+-define(MINUTE, 60).
+-define(PUBLIC, couch_replicator_auth_ibm_public).
+-define(PRIVATE, couch_replicator_auth_ibm_private).
+
+-record(worker_state, {
+    api_key_mac,
+    last_used
+}).
+
+-record(state, {
+    gun_mref,
+    gun_pid,
+    mac_key,
+    token_uri_map
+}).
+
+-record(public_entry, {
+    api_key_mac,
+    token
+}).
+
+-record(private_entry, {
+    api_key_mac,
+    api_key_uuid,
+    api_key,
+    expires_ref,
+    gun_body = [],
+    gun_status_code,
+    gun_stream_ref,
+    last_used,
+    refresh_ref,
+    token_updated_at,
+    waiters = []
+}).
+
+%% callbacks
+
+sup_initialize() ->
+    application:ensure_all_started(gun),
+    {ok, _} = gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+sup_cleanup(_) ->
+    ok = gen_server:stop(?MODULE).
+
+initialize(#httpdb{} = HttpDb) ->
+    case extract_api_key(HttpDb) of
+        {ok, APIKey} ->
+            case gen_server:call(?MODULE, {register, APIKey}) of
+                {ok, APIKeyMAC} ->
+                    WorkerState = #worker_state{api_key_mac = APIKeyMAC, 
last_used = now()},
+                    {ok, HttpDb, WorkerState};
+                {error, Reason} ->
+                    {error, Reason}
+            end;
+        {error, _} ->
+            ignore
+    end.
+
+update_headers(WorkerState, Headers) when is_list(Headers) ->
+    case get_token(WorkerState) of
+        {ok, Token} ->
+            {
+                [{~"Authorization", <<"Bearer ", Token/binary>>} | Headers],
+                update_last_used(WorkerState)
+            };
+        {error, Reason} ->
+            couch_log:warning("Error when retrieving token: ~p", [Reason]),
+            {Headers, WorkerState}
+    end.
+
+handle_response(WorkerState, _StatusCode, _Headers) ->
+    {continue, WorkerState}.
+
+cleanup(_WorkerState) ->
+    ok.
+
+get_token(#worker_state{} = WorkerState) ->
+    #worker_state{api_key_mac = APIKeyMAC} = WorkerState,
+    case ets:lookup(?PUBLIC, APIKeyMAC) of
+        [#public_entry{token = Token}] when is_binary(Token) ->
+            {ok, Token};
+        [#public_entry{}] ->
+            gen_server:call(?MODULE, {get_token, APIKeyMAC}, token_timeout());
+        [] ->
+            {error, no_token}
+    end.
+
+update_last_used(#worker_state{} = WorkerState) ->
+    #worker_state{api_key_mac = APIKeyMAC, last_used = LastUsed} = WorkerState,
+    Now = now(),
+    %% only send a message once per minute at most.
+    case Now - LastUsed > ?MINUTE of
+        true ->
+            ?MODULE ! {update_last_used, APIKeyMAC},
+            WorkerState#worker_state{last_used = Now};
+        false ->
+            WorkerState
+    end.
+
+%% gen_server callbacks.
+
+init(_) ->
+    case token_uri_map() of
+        {ok, TokenURIMap} ->
+            ?PUBLIC = ets:new(?PUBLIC, [protected, {keypos, 
#public_entry.api_key_mac}, named_table]),
+            ?PRIVATE = ets:new(?PRIVATE, [
+                private, {keypos, #private_entry.api_key_mac}, named_table
+            ]),
+            ok = config:listen_for_changes(?MODULE, nil),
+            start_gun(#state{
+                mac_key = crypto:strong_rand_bytes(32),
+                token_uri_map = TokenURIMap
+            });
+        {error, Reason} ->
+            {error, Reason}
+    end.
+
+handle_call({register, APIKey}, _From, State) ->
+    case ets:match_object(?PRIVATE, #private_entry{api_key = APIKey, _ = '_'}) 
of
+        [#private_entry{} = Entry] ->
+            {reply, {ok, Entry#private_entry.api_key_mac}, State};
+        [] ->
+            GunStreamRef = acquire_token(APIKey, State),
+            APIKeyMAC = mac(State#state.mac_key, APIKey),
+            true = ets:insert_new(?PUBLIC, #public_entry{
+                api_key_mac = APIKeyMAC
+            }),
+            true = ets:insert_new(?PRIVATE, #private_entry{
+                api_key = APIKey,
+                api_key_mac = APIKeyMAC,
+                gun_stream_ref = GunStreamRef,
+                last_used = now()
+            }),
+            {reply, {ok, APIKeyMAC}, State}
+    end;
+handle_call({get_token, APIKeyMAC}, From, State) ->
+    case ets:lookup(?PUBLIC, APIKeyMAC) of
+        [] ->
+            {reply, {error, no_such_api_key}, State};
+        [#public_entry{token = Token}] when Token /= undefined ->
+            {reply, {ok, Token}, State};
+        [#public_entry{}] ->
+            [#private_entry{} = Entry] = ets:lookup(?PRIVATE, APIKeyMAC),
+            ets:insert(?PRIVATE, Entry#private_entry{waiters = [From | 
Entry#private_entry.waiters]}),
+            case Entry of
+                #private_entry{gun_stream_ref = GunStreamRef} when
+                    GunStreamRef /= undefined
+                ->
+                    ok;
+                #private_entry{} ->
+                    self() ! {refresh_token, APIKeyMAC}
+            end,
+            {noreply, State}
+    end;
+handle_call(_Msg, _From, State) ->
+    {reply, {error, unexpected_msg}, State}.
+
+handle_cast(_Msg, State) ->
+    {noreply, State}.
+
+handle_info({update_last_used, APIKeyMAC}, State) ->
+    ets:update_element(?PRIVATE, APIKeyMAC, {#private_entry.last_used, now()}),
+    {noreply, State};
+handle_info({refresh_token, APIKeyMAC}, State) ->
+    case ets:lookup(?PRIVATE, APIKeyMAC) of
+        [] ->
+            ok;
+        [#private_entry{gun_stream_ref = undefined} = Entry] ->
+            case Entry#private_entry.last_used > 
Entry#private_entry.token_updated_at of

Review Comment:
   > but we don't set a timer to send a refresh_token event until we get that 
response.
   
   > not sure how to make it clearer, I agree it's subtle. I could use another 
atom as the default value for token_updated_at but it would never need to be 
referred to again in the code so that seems odd.
   
   What if we don't get a successful response but a 500? We reset and fire 
another refresh later, that comes to the same handler compares last_used > 
token_update_at (=undefined) so it's false, we "let it expire". It expires, job 
crashes and restarts. When registering it sees the existing entry so it goes 
back to refresh and it goes into a loop?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to