Current section

Files

Jump to
oidcc src oidcc_openid_provider.erl
Raw

src/oidcc_openid_provider.erl

-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 = #{},
static_extend_url = #{},
config_tries = 1,
config_deadline = undefined,
http_result = undefined,
retrieving = undefined,
request_id = undefined,
extra_config = #{}}).
%% 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,
static_extend_url := ExtendUrl} =
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(),
DeleteKeys =
[name,
description,
request_scopes,
issuer_or_endpoint,
local_endpoint,
client_secret,
client_id],
ExtraConfig = maps:without(DeleteKeys, Config),
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,
static_extend_url = ExtendUrl,
extra_config = ExtraConfig}}.
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_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) ->
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) ->
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,application/jwk-set+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(timeout, State) ->
trigger_config_retrieval_if_needed(State),
{noreply, State, ?GEN_TIMEOUT}.
http_async_get(Type, Url, Header, State) ->
{ok, RequestId} = oidcc_http_util:async_http(get, Url, Header),
State#state{request_id = RequestId, retrieving = Type}.
http_async_post(Type, Url, Header, ContentType, Body, State) ->
{ok, RequestId} = oidcc_http_util:async_http(post, Url, Header, ContentType, Body),
State#state{request_id = RequestId, retrieving = Type}.
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,
extra_config = ExtraConfig,
static_extend_url = StaticExtUrl}) ->
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},
{extra_config, ExtraConfig},
{static_extend_url, StaticExtUrl}],
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, []),
NewState =
State#state{keys = KeyList,
lasttime_updated = timestamp(),
request_id = undefined,
key_requests = []},
send_key_replies(KeyList, State),
case length(KeyList) > 0 of
true ->
NewState;
false ->
NewState#state{error = {no_keys, Data}}
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.
handle_http_client_crash(Reason,
#state{config_tries = Tries, retrieving = Type} = State) ->
MaxRetries = application:get_env(oidcc, provider_max_tries, 5),
case Tries >= MaxRetries of
true ->
State#state{error = Reason};
false ->
case Type of
keys ->
trigger_key_retrieval();
config ->
trigger_config_retrieval()
end,
State#state{request_id = 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).