-module(oidcc_openid_provider). -behaviour(gen_server). %% API. -export([start_link/2]). -export([stop/1]). -export([is_issuer/2]). -export([is_ready/1]). -export([get_config/1]). -export([update_config/1]). -export([update_and_get_keys/1]). -export([get_error/1]). %% gen_server. -export([init/1]). -export([handle_call/3]). -export([handle_cast/2]). -export([handle_info/2]). -export([terminate/2]). -export([code_change/3]). -record(state, { ready = false, error = undefined, key_requests = [], registration_params = #{}, id = undefined, name = undefined, desc = undefined, client_id = undefined, client_secret = undefined, request_scopes = undefined, issuer = undefined, config_ep = undefined, config = #{}, keys = [], lasttime_updated = undefined, local_endpoint = undefined, meta_data = #{}, config_tries = 1, config_deadline = undefined, http_result = undefined, retrieving = undefined, request_id = undefined }). %% API. -spec start_link(Id :: binary(), Config::map()) -> {ok, pid()}. start_link(Id, Config) -> gen_server:start_link(?MODULE, {Id, Config}, []). -spec stop(Pid ::pid()) -> ok. stop(Pid) -> gen_server:cast(Pid, stop). -spec update_config(Pid :: pid() ) -> ok. update_config(Pid) -> gen_server:call(Pid, update_config). -spec is_issuer(Issuer :: binary(), Pid :: pid() ) -> true | false. is_issuer(Issuer, Pid) -> gen_server:call(Pid, {is_issuer, Issuer}). -spec is_ready(Pid :: pid() ) -> true | false. is_ready(Pid) -> gen_server:call(Pid, is_ready). -spec get_config( Pid :: pid() ) -> {ok, Config :: map()}. get_config( Pid) -> gen_server:call(Pid, get_config). -spec update_and_get_keys( Pid :: pid() ) -> {ok, Keys :: map()}. update_and_get_keys(Pid) -> gen_server:call(Pid, update_and_get_keys, 60000). -spec get_error( Pid :: pid() ) -> {ok, term()}. get_error( Pid) -> gen_server:call(Pid, get_error). %% timeout in seconds -define(TIMEOUT, 60). -define(GEN_TIMEOUT, ?TIMEOUT * 1000). %% gen_server. init({Id, Config}) -> #{name := Name, description := Description, request_scopes := Scopes, issuer_or_endpoint := IssuerOrEndpoint, local_endpoint := LocalEndpoint } = Config, RegistrationParams = maps:get(registration_params, Config, #{}), ClientSecret = maps:get(client_secret, Config, undefined), ClientId = case ClientSecret of undefined -> undefined; _ -> maps:get(client_id, Config, undefined) end, trigger_config_retrieval(), ConfigEndpoint = to_config_endpoint(IssuerOrEndpoint), Issuer = config_ep_to_issuer(ConfigEndpoint), {ok, #state{id = Id, name = Name, desc = Description, client_id = ClientId, client_secret = ClientSecret, config_ep = ConfigEndpoint, request_scopes = Scopes, local_endpoint = LocalEndpoint, issuer = Issuer, registration_params = RegistrationParams }}. handle_call(get_config, _From, State) -> trigger_config_retrieval_if_needed(State), Conf = create_config(State), {reply, {ok, Conf}, State, ?GEN_TIMEOUT}; handle_call(update_and_get_keys, From, #state{key_requests = Requests}=State) -> trigger_config_retrieval_if_needed(State), trigger_key_retrieval(), NewRequests = [ From | Requests ], NewState = State#state{key_requests = NewRequests}, {noreply, NewState, ?GEN_TIMEOUT}; handle_call(get_error, _From, #state{error=Error} = State) -> trigger_config_retrieval_if_needed(State), {reply, {ok, Error}, State, ?GEN_TIMEOUT}; handle_call(update_config, _From, State) -> trigger_config_retrieval_if_needed(State), ok = trigger_config_retrieval(), {reply, ok, State#state{config_tries=0}, ?GEN_TIMEOUT}; handle_call({is_issuer, Issuer}, _From, #state{config=Config}=State) -> trigger_config_retrieval_if_needed(State), Result = (Issuer == maps:get(issuer, Config, undefined)), {reply, Result , State, ?GEN_TIMEOUT}; handle_call(is_ready, _From, #state{ready=Ready}=State) -> trigger_config_retrieval_if_needed(State), {reply, Ready, State, ?GEN_TIMEOUT}; handle_call(_Request, _From, State) -> trigger_config_retrieval_if_needed(State), {reply, ignored, State, ?GEN_TIMEOUT}. handle_cast(retrieve_config, #state{ request_id = undefined, config_ep=ConfigEndpoint} = State) -> trigger_config_retrieval_if_needed(State), NewState = http_async_get(config, ConfigEndpoint, [], State), {noreply, NewState, ?GEN_TIMEOUT}; handle_cast(retrieve_config, State) -> trigger_config_retrieval_if_needed(State), {noreply, State#state{config_deadline=deadline_in(120)}, ?GEN_TIMEOUT}; handle_cast(retrieve_keys, #state{ request_id = undefined, config = Config} = State) -> trigger_config_retrieval_if_needed(State), NewState = case maps:get(jwks_uri, Config, undefined) of undefined -> State#state{error=no_jwk_uri}; KeyEndpoint -> Header = [{"accept", "application/json;q=0.7,application/jwk+json"}], http_async_get(keys, KeyEndpoint, Header, State) end, {noreply, NewState, ?GEN_TIMEOUT}; handle_cast(retrieve_keys, State) -> trigger_config_retrieval_if_needed(State), trigger_key_retrieval(), {noreply, State, ?GEN_TIMEOUT}; handle_cast(register_if_needed, #state{ request_id = undefined, client_id = undefined, local_endpoint=LocalEndpoint, registration_params=RegistrationParams, config = Config } = State) -> trigger_config_retrieval_if_needed(State), BasicParams = #{application_type => <<"web">>, redirect_uris => [LocalEndpoint]}, RegParams = maps:merge(RegistrationParams, BasicParams), Body = jsone:encode(RegParams), RegistrationEndpoint = maps:get(registration_endpoint, Config), NewState = http_async_post(registration, RegistrationEndpoint, [], "application/json", Body, State), {noreply, NewState, ?GEN_TIMEOUT}; handle_cast(register_if_needed, State) -> trigger_config_retrieval_if_needed(State), {noreply, State#state{ready=true}, ?GEN_TIMEOUT}; handle_cast(stop, State) -> {stop, normal, State}; handle_cast(_Msg, State) -> trigger_config_retrieval_if_needed(State), {noreply, State, ?GEN_TIMEOUT}. handle_info({http, {RequestId, Result}}, #state{request_id = RequestId} = State) -> trigger_config_retrieval_if_needed(State), NewState = handle_http_result(State#state{http_result = Result}), {noreply, NewState, ?GEN_TIMEOUT}; handle_info(_Info, State) -> trigger_config_retrieval_if_needed(State), {noreply, State, ?GEN_TIMEOUT}. http_async_get(Type, Url, Header, State) -> case oidcc_http_util:async_http(get, Url, Header) of {ok, RequestId} -> State#state{request_id = RequestId, retrieving=Type}; Error -> State#state{error = Error} end. http_async_post(Type, Url, Header, ContentType, Body, State) -> case oidcc_http_util:async_http(post, Url, Header, ContentType, Body) of {ok, RequestId} -> State#state{request_id = RequestId, retrieving=Type}; Error -> State#state{error = Error} end. terminate(_Reason, _State) -> ok. code_change(_OldVsn, State, _Extra) -> {ok, State}. handle_http_result(true, _, Header, Body, config, State) -> handle_config(Body, Header, State); handle_http_result(true, _, Header, Body, keys, State) -> handle_keys(Body, Header, State); handle_http_result(true, _, Header, Body, registration, State) -> handle_registration(Body, Header, State); handle_http_result(false, Status, _Header, Body, Retrieve, State) -> State#state{error = {retrieving, Retrieve, Status, Body}, config_deadline = deadline_in(600)}. handle_http_result(#state{http_result={error, Reason}} = State) -> handle_http_client_crash(Reason, State); handle_http_result(#state{retrieving=Retrieve, http_result={{_Proto, Status, _StatusName}, Header, InBody} } = State) -> GoodStatus = (Status >= 200) and (Status < 300), {ok, Body} = oidcc_http_util:uncompress_body_if_needed(InBody, Header), handle_http_result(GoodStatus, Status, Header, Body, Retrieve, State). create_config(#state{id = Id, desc = Desc, client_id = ClientId, client_secret = ClientSecret, config_ep = ConfEp, config=Config, keys = Keys, issuer = Issuer, lasttime_updated = LastTimeUpdated, ready = Ready, local_endpoint = LocalEndpoint, name = Name, request_scopes = Scopes, meta_data = MetaData, config_deadline = ConfDeadline }) -> StateList = [{id, Id}, {name, Name}, {description, Desc}, {client_id, ClientId}, {client_secret, ClientSecret}, {config_endpoint, ConfEp}, {lasttime_updated, LastTimeUpdated}, {ready, Ready}, {local_endpoint, LocalEndpoint}, {keys, Keys}, {request_scopes, Scopes}, {issuer, Issuer}, {meta_data, MetaData}, {config_deadline, ConfDeadline}], maps:merge(Config, maps:from_list(StateList)). handle_config(Data, Header, #state{issuer=Issuer} = State) -> Config = decode_json(Data), ConfIssuer = maps:get(issuer, Config, undefined), SameIssuer = is_same_issuer(ConfIssuer, Issuer), AuthCodeFlow = supports_auth_code(Config), case {SameIssuer, AuthCodeFlow} of {true, true} -> Deadline = header_to_deadline(Header), trigger_registration(), State#state{config = Config, issuer=ConfIssuer, request_id=undefined, config_deadline=Deadline}; {true, false} -> Error = no_authcode_support, State#state{error = Error, ready=false, request_id=undefined}; _ -> Deadline = deadline_in(600), Error = {bad_issuer_config, Issuer, ConfIssuer, Data}, State#state{error = Error, ready=false, request_id=undefined, config_deadline=Deadline} end. supports_auth_code(#{response_types_supported := ResponseTypes} = Config) -> Code = <<"code">>, AuthCode = <<"authorization_code">>, GrantTypes = maps:get(grant_types_supported, Config, [AuthCode, <<"implicit">>]), CodeResponse = lists:member(Code, ResponseTypes), AuthGrant = lists:member(AuthCode, GrantTypes), CodeResponse and AuthGrant; supports_auth_code(_) -> false. header_to_deadline(Header) -> Cache = lists:keyfind(<<"cache-control">>, 1, Header), Delta = try cache_deadline(Cache) catch _:_ -> 3600 end, deadline_in(Delta). cache_deadline({_, Cache}) -> Entries = binary:split(Cache, [<<",">>, <<"=">>, <<" ">>], [global, trim_all]), MaxAge = fun(Entry, true) -> binary_to_integer(Entry); (<<"max-age">>, _) -> true; (_, Res) -> Res end, lists:foldl(MaxAge, false, Entries). deadline_in(Seconds) -> timestamp() + Seconds. handle_keys(Data, _Header, State) -> %TODO: maybe also implement a keys deadline KeyConfig=decode_json(Data), KeyList = maps:get(keys, KeyConfig, []), Keys = extract_supported_keys(KeyList, []), NewState = State#state{keys = Keys, lasttime_updated = timestamp(), request_id = undefined}, send_key_replies(Keys, State), case length(Keys) > 0 of true -> NewState; false -> NewState#state{error = {no_keys, Data}, request_id = undefined} end. send_key_replies(Keys, #state{key_requests = Requests}) -> Send = fun(From, _) -> gen_server:reply(From, {ok, Keys}) end, lists:foldl(Send, ok, Requests). handle_registration(Data, _Header, State) -> %TODO: implement update at expire data/time or retrieval when needed MetaData=decode_json(Data), ClientId = maps:get(client_id, MetaData, undefined), ClientSecret = maps:get(client_secret, MetaData, undefined), ClientSecretExpire = maps:get(client_secret_expires_at, MetaData, undefined), case is_binary(ClientId) and is_binary(ClientSecret) and is_number(ClientSecretExpire) of true -> State#state{meta_data = MetaData, client_id = ClientId, client_secret = ClientSecret, ready = true, lasttime_updated = timestamp(), request_id = undefined}; false -> State#state{error=no_clientid, meta_data=MetaData, ready = false, client_id=undefined, client_secret = undefined, request_id = undefined} end. decode_json(Data) -> try jsone:decode(Data, [{keys, attempt_atom}, {object_format, map}]) catch error:badarg -> #{} end. extract_supported_keys(Keys, List) -> extract_supported_keys(Keys, any, List). extract_supported_keys([], _, List) -> List; extract_supported_keys([#{ kty := Kty0} = Map|T], ListTypeIn, List) -> Kty = case Kty0 of <<"RSA">> -> rsa; _ -> Kty0 end, Alg0 = maps:get(alg, Map, undefined), Alg = case Alg0 of <<"RS256">> -> rs256; undefined -> undefined; _ -> unknown end, Kid = maps:get(kid, Map, undefined), Use0 = maps:get(use, Map, undefined), {Use, ListType} = case {Use0, ListTypeIn} of {<<"sig">>, any} -> {sign, combined}; {<<"sig">>, combined} -> {sign, combined}; {<<"enc">>, any} -> {enc, combined}; {<<"enc">>, combined} -> {enc, combined}; {undefined, any} -> {sign, pure_sign}; {undefined, pure_sign} -> {sign, pure_sign}; {_, cobined} -> {Use0, combined}; {_, any} -> {Use0, combined} end, Key = case Kty of rsa -> N0 = maps:get(n, Map), E0 = maps:get(e, Map), N1 = binary:decode_unsigned(base64url:decode(N0)), E1 = binary:decode_unsigned(base64url:decode(E0)), [E1, N1]; _ -> unknown end, case (Use /= unknown) of true -> Update = #{kty => Kty, use => Use, alg => Alg, key => Key, kid => Kid }, Entry = maps:merge(Map, Update), extract_supported_keys(T, ListType, [Entry | List]); _ -> %% bad key, do exclude this provider [] end; extract_supported_keys(_, _, _) -> []. handle_http_client_crash(Reason, #state{config_tries=Tries} = State) -> MaxRetries = application:get_env(oidcc, provider_max_tries, 5), case Tries >= MaxRetries of true -> State#state{error = Reason}; false -> State#state{request_id=undefined, retrieving=undefined, http_result={}, config_tries=Tries+1, config_deadline = deadline_in(300)} end. trigger_config_retrieval() -> gen_server:cast(self(), retrieve_config). trigger_config_retrieval_if_needed(#state{config_deadline=Deadline} = State) when is_integer(Deadline) -> Soon = timestamp() + ?TIMEOUT, case Soon >= Deadline of true -> trigger_config_retrieval(), {ok, State#state{config_deadline = undefined}}; _ -> {ok, State} end; trigger_config_retrieval_if_needed(State) -> {ok, State}. trigger_key_retrieval() -> gen_server:cast(self(), retrieve_keys). trigger_registration() -> gen_server:cast(self(), register_if_needed). timestamp() -> erlang:system_time(seconds). to_config_endpoint(IssuerOrEndpoint) -> Slash = <<"/">>, Config = <<".well-known/openid-configuration">>, ConfigS = << Slash/binary, Config/binary >>, Pos = byte_size(IssuerOrEndpoint) - 33, case binary:match(IssuerOrEndpoint, ConfigS) of {Pos, 33} -> Endpoint = IssuerOrEndpoint, Endpoint; _ -> Issuer = IssuerOrEndpoint, case binary:last(Issuer) of $/ -> << Issuer/binary, Config/binary>>; _ -> << Issuer/binary, ConfigS/binary>> end end. config_ep_to_issuer(ConfigEp) -> [Issuer] = binary:split(ConfigEp, [<<"/.well-known/openid-configuration">>], [trim_all, global]), Issuer. is_same_issuer(Config, Issuer) -> Slash = <<"/">>, IssuerSlash = << Issuer/binary, Slash/binary >>, (Config =:= Issuer) or (Config =:= IssuerSlash).