src/oidcc_authorization.erl

%%%-------------------------------------------------------------------
%% @doc Functions to start an OpenID Connect Authorization
%% @end
%% @since 3.0.0
%%%-------------------------------------------------------------------
-module(oidcc_authorization).

-feature(maybe_expr, enable).

-include("oidcc_client_context.hrl").
-include("oidcc_provider_configuration.hrl").

-include_lib("jose/include/jose_jwk.hrl").

-export([create_redirect_url/2]).

-export_type([error/0]).
-export_type([pkce/0]).
-export_type([opts/0]).

-type pkce() :: #{challenge := binary(), method := binary()}.
%% Configure PKCE for authorization
%%
%% See [https://datatracker.ietf.org/doc/html/rfc7636#section-4.3]

-type opts() ::
    #{
        scopes => oidcc_scope:scopes(),
        state => binary(),
        nonce => binary(),
        pkce => pkce() | undefined,
        redirect_uri := uri_string:uri_string(),
        url_extension => oidcc_http_util:query_params()
    }.
%% Configure authorization redirect url
%%
%% See [https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest]
%%
%% <h2>Parameters</h2>
%%
%% <ul>
%%   <li>`scopes' - list of scopes to request (defaults to `[<<"openid">>]')</li>
%%   <li>`state' - state to pass to the provider</li>
%%   <li>`nonce' - nonce to pass to the provider</li>
%%   <li>`pkce' - pkce arguments to pass to the provider</li>
%%   <li>`redirect_uri' - redirect target after authorization is completed</li>
%%   <li>`url_extension' - add custom query parameters to the authorization url</li>
%% </ul>

-type error() :: {grant_type_not_supported, authorization_code}.

%% @doc
%% Create Auth Redirect URL
%%
%% For a high level interface using {@link oidcc_provider_configuration_worker}
%% see {@link oidcc:create_redirect_url/4}.
%%
%% <h2>Examples</h2>
%%
%% ```
%% {ok, ClientContext} =
%%     oidcc_client_context:from_configuration_worker(provider_name,
%%                                                    <<"client_id">>,
%%                                                    <<"client_secret">>),
%%
%% {ok, RedirectUri} =
%%     oidcc_authorization:create_redirect_url(ClientContext,
%%                                             #{redirect_uri: <<"https://my.server/return"}),
%%
%% %% RedirectUri = https://my.provider/auth?scope=openid&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn
%% '''
%% @end
%% @since 3.0.0
-spec create_redirect_url(ClientContext, Opts) -> {ok, Uri} | {error, error()} when
    ClientContext :: oidcc_client_context:t(),
    Opts :: opts(),
    Uri :: uri_string:uri_string().
create_redirect_url(#oidcc_client_context{} = ClientContext, Opts) ->
    #oidcc_client_context{provider_configuration = ProviderConfiguration} = ClientContext,

    #oidcc_provider_configuration{
        authorization_endpoint = AuthEndpoint, grant_types_supported = GrantTypesSupported
    } =
        ProviderConfiguration,

    case lists:member(<<"authorization_code">>, GrantTypesSupported) of
        true ->
            QueryParams = redirect_params(ClientContext, Opts),
            QueryString = uri_string:compose_query(QueryParams),

            {ok, [AuthEndpoint, <<"?">>, QueryString]};
        false ->
            {error, {grant_type_not_supported, authorization_code}}
    end.

-spec redirect_params(ClientContext, Opts) -> oidcc_http_util:query_params() when
    ClientContext :: oidcc_client_context:t(),
    Opts :: opts().
redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opts) ->
    QueryParams =
        [
            {<<"response_type">>, maps:get(response_type, Opts, <<"code">>)},
            {<<"client_id">>, ClientId},
            {<<"redirect_uri">>, maps:get(redirect_uri, Opts)}
            | maps:get(url_extension, Opts, [])
        ],
    QueryParams1 = maybe_append(<<"state">>, maps:get(state, Opts, undefined), QueryParams),
    QueryParams2 =
        maybe_append(<<"nonce">>, maps:get(nonce, Opts, undefined), QueryParams1),
    QueryParams3 = append_code_challenge(maps:get(pkce, Opts, undefined), QueryParams2),
    QueryParams4 = oidcc_scope:query_append_scope(
        maps:get(scopes, Opts, [openid]), QueryParams3
    ),
    attempt_request_object(QueryParams4, ClientContext).

-spec append_code_challenge(
    Pkce :: pkce() | undefined, QueryParams :: oidcc_http_util:query_params()
) ->
    oidcc_http_util:query_params().
append_code_challenge(#{challenge := Challenge, method := Method}, QueryParams) ->
    [{<<"code_challenge">>, Challenge}, {<<"code_challenge_method">>, Method} | QueryParams];
append_code_challenge(undefined, QueryParams) ->
    QueryParams.

-spec maybe_append(Key, Value, QueryParams) -> QueryParams when
    Key :: unicode:chardata(),
    Value :: unicode:chardata() | true | undefined,
    QueryParams :: oidcc_http_util:query_params().
maybe_append(_Key, undefined, QueryParams) ->
    QueryParams;
maybe_append(Key, Value, QueryParams) ->
    [{Key, Value} | QueryParams].

-spec attempt_request_object(QueryParams, ClientContext) -> QueryParams when
    QueryParams :: oidcc_http_util:query_params(),
    ClientContext :: oidcc_client_context:t().
attempt_request_object(QueryParams, #oidcc_client_context{
    provider_configuration = #oidcc_provider_configuration{request_parameter_supported = false}
}) ->
    QueryParams;
attempt_request_object(QueryParams, #oidcc_client_context{
    client_id = ClientId,
    client_secret = ClientSecret,
    provider_configuration = #oidcc_provider_configuration{
        issuer = Issuer,
        request_parameter_supported = true,
        request_object_signing_alg_values_supported = SigningAlgSupported,
        request_object_encryption_alg_values_supported = EncryptionAlgSupported,
        request_object_encryption_enc_values_supported = EncryptionEncSupported
    },
    jwks = Jwks
}) ->
    SigningJwks =
        case oidcc_jwt_util:client_secret_oct_keys(SigningAlgSupported, ClientSecret) of
            none ->
                Jwks;
            SigningOctJwk ->
                oidcc_jwt_util:merge_jwks(Jwks, SigningOctJwk)
        end,
    EncryptionJwks =
        case oidcc_jwt_util:client_secret_oct_keys(EncryptionAlgSupported, ClientSecret) of
            none ->
                Jwks;
            EncryptionOctJwk ->
                oidcc_jwt_util:merge_jwks(Jwks, EncryptionOctJwk)
        end,

    MaxClockSkew =
        case application:get_env(oidcc, max_clock_skew) of
            undefined -> 0;
            ClockSkew -> ClockSkew
        end,

    Claims = maps:merge(
        #{
            <<"iss">> => ClientId,
            <<"aud">> => Issuer,
            <<"jti">> => random_string(32),
            <<"iat">> => os:system_time(seconds),
            <<"exp">> => os:system_time(seconds) + 30,
            <<"nbf">> => os:system_time(seconds) - MaxClockSkew
        },
        maps:from_list(QueryParams)
    ),
    Jwt = jose_jwt:from(Claims),

    case oidcc_jwt_util:sign(Jwt, SigningJwks, deprioritize_none_alg(SigningAlgSupported)) of
        {error, no_supported_alg_or_key} ->
            QueryParams;
        {ok, SignedRequestObject} ->
            case
                oidcc_jwt_util:encrypt(
                    SignedRequestObject,
                    EncryptionJwks,
                    deprioritize_none_alg(EncryptionAlgSupported),
                    EncryptionEncSupported
                )
            of
                {ok, EncryptedRequestObject} ->
                    [{<<"request">>, EncryptedRequestObject} | essential_params(QueryParams)];
                {error, no_supported_alg_or_key} ->
                    [{<<"request">>, SignedRequestObject} | essential_params(QueryParams)]
            end
    end.

-spec essential_params(QueryParams :: oidcc_http_util:query_params()) ->
    oidcc_http_util:query_params().
essential_params(QueryParams) ->
    lists:filter(
        fun
            ({<<"scope">>, _Value}) -> true;
            ({<<"response_type">>, _Value}) -> true;
            ({<<"client_id">>, _Value}) -> true;
            ({<<"redirect_uri">>, _Value}) -> true;
            (_Other) -> false
        end,
        QueryParams
    ).

-spec deprioritize_none_alg(Algorithms :: [binary()]) -> [binary()].
deprioritize_none_alg(Algorithms) ->
    lists:usort(
        fun
            (<<"none">>, _B) -> false;
            (_A, <<"none">>) -> true;
            (_A, _B) -> true
        end,
        Algorithms
    ).

-spec random_string(Bytes :: pos_integer()) -> binary().
random_string(Bytes) ->
    base64:encode(crypto:strong_rand_bytes(Bytes), #{mode => urlsafe, padding => false}).