src/oidcc_cowboy_load_userinfo.erl

%%%-------------------------------------------------------------------
%% @doc Validate extracted authorization token using userinfo retrieval.
%%
%% See: [https://openid.net/specs/openid-connect-core-1_0.html#UserInfo]
%%
%% This middleware should be used together with
%% {@link oidcc_cowboy_extract_authorization}.
%%
%% This middleware will send a userinfo request for ever request. To avoid this,
%% provide a `cache' to {@link opts()}.
%%
%% <h2>Usage</h2>
%%
%% ```
%% OidccCowboyOpts = #{
%%     provider => openid_confi_provider_name,
%%     client_id => <<"client_id">>,
%%     client_secret => <<"client_secret">>
%% },
%% Dispatch = cowboy_router:compile([
%%     {'_', [
%%         %% ...
%%     ]}
%% ]),
%% {ok, _} = cowboy:start_clear(http, [{port, 8080}], #{
%%     middlewares => [
%%         oidcc_cowboy_extract_authorization,
%%         oidcc_cowboy_load_userinfo,
%%         cowboy_router,
%%         cowboy_handler
%%     ],
%%     env => #{
%%         dispatch => Dispatch,
%%         oidcc_cowboy_load_userinfo => OidccCowboyOpts
%%     }
%% })
%% '''
%% @end
%% @since 2.0.0
%%%-------------------------------------------------------------------
-module(oidcc_cowboy_load_userinfo).

-behaviour(cowboy_middleware).

-export([execute/2]).

-export_type([opts/0]).

-type opts() :: #{
    provider := gen_server:server_ref(),
    client_id := binary(),
    client_secret := binary(),
    userinfo_retrieve_opts => oidcc_userinfo:retrieve_opts(),
    cache => oidcc_cowboy_cache:t(),
    send_inactive_token_response => fun(
        (Req :: cowboy_req:req(), Env :: cowboy_middleware:env()) ->
            {ok, cowboy_req:req(), cowboy_middleware:env()} | {stop, cowboy_req:req()}
    )
}.
%% Options for the middleware
%%
%% <h2>Options</h2>
%%
%% <ul>
%%   <li>`provider' - name of the
%%     {@link oidcc_provider_configuration_worker}</li>
%%   <li>`client_id' - OAuth Client ID to use for the userinfo retrieval</li>
%%   <li>`client_secret' - OAuth Client Secret to use for the userinfo
%%     retrieval</li>
%%   <li>`userinfo_retrieve_opts' - Options to pass to userinfo loading</li>
%%   <li>`send_inactive_token_response' - Customize Error Response for inactive
%%     token</li>
%%   <li>`cache' - Cache userinfo response - See {@link oidcc_cowboy_cache}</li>
%% </ul>

%% @private
execute(#{oidcc_cowboy_extract_authorization := undefined} = Req, #{?MODULE := _Opts} = Env) ->
    {ok, maps:put(?MODULE, undefined, Req), Env};
execute(#{oidcc_cowboy_extract_authorization := Token} = Req, #{?MODULE := Opts} = Env) ->
    Provider = maps:get(provider, Opts),
    ClientId = maps:get(client_id, Opts),
    ClientSecret = maps:get(client_secret, Opts),
    UserinfoRetrieveOpts0 = maps:get(userinfo_retrieve_opts, Opts, #{}),
    UserinfoRetrieveOpts = maps:put(expected_subject, any, UserinfoRetrieveOpts0),
    SendInactiveTokenResponse = maps:get(
        send_inactive_token_response, Opts, fun send_inactive_token_response/2
    ),
    Cache = maps:get(cache, Opts, oidcc_cowboy_cache_noop),

    case Cache:get(userinfo, Token, Req, Env) of
        {ok, #{} = Claims} ->
            {ok, maps:put(?MODULE, Claims, Req), Env};
        miss ->
            case
                oidcc:retrieve_userinfo(
                    Token, Provider, ClientId, ClientSecret, UserinfoRetrieveOpts
                )
            of
                {ok, #{} = Claims} ->
                    Cache:put(userinfo, Token, Claims, Req, Env),
                    {ok, maps:put(?MODULE, Claims, Req), Env};
                {error, {http_error, 401, _Body}} ->
                    SendInactiveTokenResponse(maps:put(?MODULE, undefined, Req), Env);
                {error, Reason} ->
                    erlang:error(Reason)
            end
    end;
execute(#{oidcc_cowboy_extract_authorization := _Token} = _Req, #{} = _Env) ->
    erlang:error(no_config_provided);
execute(#{} = _Req, #{?MODULE := _Opts} = _Env) ->
    erlang:error(no_oidcc_cowboy_extract_authorization).

send_inactive_token_response(Req0, _Env) ->
    Req = cowboy_req:reply(
        401,
        #{<<"content-type">> => <<"text/plain">>},
        <<"The provided token is inactive">>,
        Req0
    ),
    {stop, Req}.