src/oidcc_authorization.erl

-module(oidcc_authorization).

-feature(maybe_expr, enable).

-include("internal/doc.hrl").
?MODULEDOC("Functions to start an OpenID Connect Authorization").
?MODULEDOC(#{since => <<"3.0.0">>}).

-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([opts/0]).

?DOC("""
Configure authorization redirect URL.

See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest.

## Parameters

* `scopes` - list of scopes to request (defaults to `[<<"openid">>]`)
* `state` - state to pass to the provider
* `nonce` - nonce to pass to the provider
* `purpose` - purpose of the authorization request, see [https://cdn.connectid.com.au/specifications/oauth2-purpose-01.html]
* `require_purpose` - whether to require a `purpose` value
* `pkce_verifier` - PKCE verifier (random string), see [https://datatracker.ietf.org/doc/html/rfc7636#section-4.1]
* `require_pkce` - whether to require PKCE when getting the token
* `redirect_uri` - redirect target after authorization is completed
* `url_extension` - add custom query parameters to the authorization URL
* `response_mode` - response mode to use (defaults to `<<"query">>`)
""").
?MODULEDOC(#{since => <<"3.0.0">>}).
-type opts() ::
    #{
        scopes => oidcc_scope:scopes(),
        state => binary(),
        nonce => binary(),
        pkce_verifier => binary(),
        require_pkce => boolean(),
        purpose => binary(),
        require_purpose => boolean(),
        redirect_uri => uri_string:uri_string(),
        url_extension => oidcc_http_util:query_params(),
        response_mode => binary()
    }.

?MODULEDOC(#{since => <<"3.0.0">>}).
-type error() ::
    {grant_type_not_supported, authorization_code}
    | par_required
    | request_object_required
    | pkce_verifier_required
    | purpose_required
    | no_supported_code_challenge
    | oidcc_http_util:error().

-telemetry_event(#{
    event => [oidcc, par_request, start],
    description => <<"Emitted at the start of executing a PAR request">>,
    measurements => <<"#{system_time => non_neg_integer()}">>,
    metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).

-telemetry_event(#{
    event => [oidcc, par_request, stop],
    description => <<"Emitted at the end of executing a PAR request">>,
    measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
    metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).

-telemetry_event(#{
    event => [oidcc, par_request, exception],
    description => <<"Emitted at the end of executing a PAR request">>,
    measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
    metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).

?DOC("""
Create Auth Redirect URL.

For a high level interface using `m:oidcc_provider_configuration_worker`
see `oidcc:create_redirect_url/4`.

## Examples

```erlang
{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
```
""").
?DOC(#{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,

    maybe
        true ?= lists:member(<<"authorization_code">>, GrantTypesSupported),
        {ok, QueryParams} ?= redirect_params(ClientContext, Opts),
        QueryString = uri_string:compose_query(QueryParams),
        {ok, [AuthEndpoint, <<"?">>, QueryString]}
    else
        {error, Reason} ->
            {error, Reason};
        false ->
            {error, {grant_type_not_supported, authorization_code}}
    end.

-spec redirect_params(ClientContext, Opts) -> {ok, oidcc_http_util:query_params()} when
    ClientContext :: oidcc_client_context:t(),
    Opts :: opts().
redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opts) ->
    UrlExtension = maps:get(url_extension, Opts, []),
    QueryParams0 =
        [
            {<<"response_type">>, maps:get(response_type, Opts, <<"code">>)},
            {<<"client_id">>, ClientId},
            {<<"redirect_uri">>, maps:get(redirect_uri, Opts)}
        ],
    QueryParams1 = maybe_append(<<"state">>, maps:get(state, Opts, undefined), QueryParams0),
    QueryParams2 = maybe_append(<<"nonce">>, maps:get(nonce, Opts, undefined), QueryParams1),
    QueryParams3 = maybe_append(<<"purpose">>, maps:get(purpose, Opts, undefined), QueryParams2),
    QueryParams4 =
        case maps:get(response_mode, Opts, <<"query">>) of
            <<"query">> ->
                QueryParams3;
            ResponseMode when is_binary(ResponseMode) ->
                [{<<"response_mode">>, ResponseMode} | QueryParams3]
        end,
    maybe
        ok ?= validate_purpose_required(Opts),
        {ok, QueryParams5} ?=
            append_code_challenge(
                Opts, QueryParams4, ClientContext
            ),
        QueryParams6 = oidcc_scope:query_append_scope(
            maps:get(scopes, Opts, [openid]), QueryParams5
        ),
        QueryParams7 = maybe_append_dpop_jkt(QueryParams6, ClientContext),
        {ok, QueryParams} ?= attempt_request_object(QueryParams7, ClientContext, UrlExtension),
        attempt_par(QueryParams, ClientContext, Opts)
    end.

-spec append_code_challenge(Opts, QueryParams, ClientContext) ->
    {ok, oidcc_http_util:query_params()} | {error, error()}
when
    Opts :: opts(),
    QueryParams :: oidcc_http_util:query_params(),
    ClientContext :: oidcc_client_context:t().
append_code_challenge(#{pkce_verifier := CodeVerifier} = Opts, QueryParams, ClientContext) ->
    #oidcc_client_context{provider_configuration = ProviderConfiguration} = ClientContext,
    #oidcc_provider_configuration{code_challenge_methods_supported = CodeChallengeMethodsSupported} =
        ProviderConfiguration,
    RequirePkce = maps:get(require_pkce, Opts, false),
    case CodeChallengeMethodsSupported of
        undefined when RequirePkce ->
            {error, no_supported_code_challenge};
        undefined ->
            {ok, QueryParams};
        Methods when is_list(Methods) ->
            case
                {
                    lists:member(<<"S256">>, CodeChallengeMethodsSupported),
                    lists:member(<<"plain">>, CodeChallengeMethodsSupported)
                }
            of
                {true, _PlainSupported} ->
                    CodeChallenge = base64:encode(crypto:hash(sha256, CodeVerifier), #{
                        mode => urlsafe, padding => false
                    }),
                    {ok, [
                        {<<"code_challenge">>, CodeChallenge},
                        {<<"code_challenge_method">>, <<"S256">>}
                        | QueryParams
                    ]};
                {false, true} ->
                    {ok, [
                        {<<"code_challenge">>, CodeVerifier},
                        {<<"code_challenge_method">>, <<"plain">>}
                        | QueryParams
                    ]};
                {false, false} when RequirePkce ->
                    {error, no_supported_code_challenge};
                {false, false} ->
                    {ok, QueryParams}
            end
    end;
append_code_challenge(#{require_pkce := true}, _QueryParams, _ClientContext) ->
    {error, pkce_verifier_required};
append_code_challenge(_Opts, QueryParams, _ClientContext) ->
    {ok, 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 validate_purpose_required(Opts) -> ok | {error, purpose_required} when
    Opts :: opts().
validate_purpose_required(#{purpose := Purpose}) when is_binary(Purpose) ->
    ok;
validate_purpose_required(#{purpose_required := true}) ->
    {error, purpose_required};
validate_purpose_required(_Opts) ->
    ok.

-spec maybe_append_dpop_jkt(QueryParams, ClientContext) ->
    QueryParams
when
    ClientContext :: oidcc_client_context:t(),
    QueryParams :: oidcc_http_util:query_params().
maybe_append_dpop_jkt(
    QueryParams,
    #oidcc_client_context{
        client_jwks = #jose_jwk{} = ClientJwks,
        provider_configuration = #oidcc_provider_configuration{
            dpop_signing_alg_values_supported = [_ | _]
        }
    }
) ->
    case oidcc_jwt_util:thumbprint(ClientJwks) of
        {ok, Thumbprint} ->
            [{<<"dpop_jkt">>, Thumbprint} | QueryParams];
        error ->
            QueryParams
    end;
maybe_append_dpop_jkt(QueryParams, _ClientContext) ->
    QueryParams.

-spec attempt_request_object(QueryParams, ClientContext, UrlExtension) ->
    {ok, QueryParams} | {error, error()}
when
    QueryParams :: oidcc_http_util:query_params(),
    UrlExtension :: oidcc_http_util:query_params(),
    ClientContext :: oidcc_client_context:t().
attempt_request_object(
    QueryParams,
    #oidcc_client_context{
        client_id = ClientId,
        client_secret = ClientSecret,
        client_jwks = ClientJwks,
        provider_configuration = #oidcc_provider_configuration{
            issuer = Issuer,
            request_parameter_supported = true,
            require_signed_request_object = RequireSignedRequestObject,
            request_object_signing_alg_values_supported = SigningAlgSupported0,
            request_object_encryption_alg_values_supported = EncryptionAlgSupported0,
            request_object_encryption_enc_values_supported = EncryptionEncSupported0
        },
        jwks = Jwks
    },
    UrlExtension
) when ClientSecret =/= unauthenticated ->
    SigningAlgSupported =
        case SigningAlgSupported0 of
            undefined -> [];
            SigningAlgs -> SigningAlgs
        end,
    EncryptionAlgSupported =
        case EncryptionAlgSupported0 of
            undefined -> [];
            EncryptionAlgs -> EncryptionAlgs
        end,
    EncryptionEncSupported =
        case EncryptionEncSupported0 of
            undefined -> [];
            EncryptionEncs -> EncryptionEncs
        end,

    JwksWithClientJwks =
        case ClientJwks of
            none -> Jwks;
            #jose_jwk{} -> oidcc_jwt_util:merge_jwks(Jwks, ClientJwks)
        end,

    SigningJwks = oidcc_jwt_util:merge_client_secret_oct_keys(
        JwksWithClientJwks, SigningAlgSupported, ClientSecret
    ),
    EncryptionJwks = oidcc_jwt_util:merge_client_secret_oct_keys(
        JwksWithClientJwks, EncryptionAlgSupported, ClientSecret
    ),

    MaxClockSkew =
        case application:get_env(oidcc, max_clock_skew) of
            undefined -> 0;
            {ok, 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 ++ UrlExtension)
    ),
    Jwt = jose_jwt:from(Claims),

    case oidcc_jwt_util:sign(Jwt, SigningJwks, deprioritize_none_alg(SigningAlgSupported)) of
        {error, no_supported_alg_or_key} when RequireSignedRequestObject ->
            {error, request_object_required};
        {error, no_supported_alg_or_key} ->
            {ok, QueryParams ++ UrlExtension};
        {ok, SignedRequestObject} ->
            case
                oidcc_jwt_util:encrypt(
                    SignedRequestObject,
                    EncryptionJwks,
                    deprioritize_none_alg(EncryptionAlgSupported),
                    EncryptionEncSupported
                )
            of
                {ok, EncryptedRequestObject} ->
                    {ok,
                        [{<<"request">>, EncryptedRequestObject} | essential_params(QueryParams)] ++
                            UrlExtension};
                {error, no_supported_alg_or_key} ->
                    {ok,
                        [{<<"request">>, SignedRequestObject} | essential_params(QueryParams)] ++
                            UrlExtension}
            end
    end;
attempt_request_object(
    _QueryParams,
    #oidcc_client_context{
        provider_configuration = #oidcc_provider_configuration{require_signed_request_object = true}
    },
    _UrlExtension
) ->
    {error, request_object_required};
attempt_request_object(QueryParams, _ClientContext, UrlExtension) ->
    {ok, QueryParams ++ UrlExtension}.

-spec attempt_par(QueryParams, ClientContext, Opts) ->
    {ok, QueryParams} | {error, error()}
when
    QueryParams :: oidcc_http_util:query_params(),
    ClientContext :: oidcc_client_context:t(),
    Opts :: opts().
attempt_par(
    _QueryParams,
    #oidcc_client_context{
        provider_configuration = #oidcc_provider_configuration{
            require_pushed_authorization_requests = true,
            pushed_authorization_request_endpoint = undefined
        }
    },
    _Opts
) ->
    {error, par_required};
attempt_par(
    QueryParams,
    #oidcc_client_context{
        provider_configuration = #oidcc_provider_configuration{
            pushed_authorization_request_endpoint = undefined
        }
    },
    _Opts
) ->
    {ok, QueryParams};
attempt_par(
    QueryParams,
    #oidcc_client_context{
        client_id = ClientId,
        provider_configuration =
            #oidcc_provider_configuration{
                issuer = Issuer,
                token_endpoint_auth_methods_supported = SupportedAuthMethods,
                token_endpoint_auth_signing_alg_values_supported = SigningAlgs,
                pushed_authorization_request_endpoint = PushedAuthorizationRequestEndpoint0
            }
    } = ClientContext,
    Opts
) ->
    Header0 = [{"accept", "application/json"}],

    TelemetryOpts = #{
        topic => [oidcc, par_request], extra_meta => #{issuer => Issuer, client_id => ClientId}
    },

    RequestOpts = maps:get(request_opts, Opts, #{}),

    maybe
        {ok, {Body0, Header}, AuthMethod} ?=
            oidcc_auth_util:add_client_authentication(
                QueryParams,
                Header0,
                SupportedAuthMethods,
                SigningAlgs,
                Opts,
                ClientContext
            ),
        %% ensure no duplicate parameters (such as client_id)
        Body = lists:ukeysort(1, Body0),
        PushedAuthorizationRequestEndpoint = oidcc_auth_util:maybe_mtls_endpoint(
            PushedAuthorizationRequestEndpoint0,
            AuthMethod,
            <<"pushed_authorization_request_endpoint">>,
            ClientContext
        ),
        Request =
            {PushedAuthorizationRequestEndpoint, Header, "application/x-www-form-urlencoded",
                uri_string:compose_query(Body)},
        {ok, {{json, ParResponse}, _Headers}} ?=
            oidcc_http_util:request(post, Request, TelemetryOpts, RequestOpts),
        #{<<"request_uri">> := ParRequestUri} ?= ParResponse,
        {ok, [{<<"request_uri">>, ParRequestUri}, {<<"client_id">>, ClientId}]}
    else
        {error, Reason} -> {error, Reason};
        #{} = JsonResponse -> {error, {http_error, 201, JsonResponse}}
    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;
            (_Other) -> false
        end,
        QueryParams
    ).

-spec deprioritize_none_alg(Algorithms :: [binary()]) -> [binary()].
deprioritize_none_alg(Algorithms) ->
    {WithNone, WithoutNone} = lists:partition(
        fun
            (<<"none">>) -> true;
            (_) -> false
        end,
        Algorithms
    ),
    WithoutNone ++ WithNone.

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