src/oidcc_client_registration.erl

%%%-------------------------------------------------------------------
%% @doc Dynamic Client Registration Utilities
%%
%% See [https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata]
%%
%% <h2>Records</h2>
%%
%% To use the record, import the definition:
%%
%% ```
%% -include_lib(["oidcc/include/oidcc_client_registration.hrl"]).
%% '''
%%
%% <h2>Telemetry</h2>
%%
%% See {@link 'Elixir.Oidcc.ClientRegistration'}
%% @end
%% @since 3.0.0
%%%-------------------------------------------------------------------
-module(oidcc_client_registration).

-feature(maybe_expr, enable).

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

-export([register/3]).

-export_type([error/0]).
-export_type([opts/0]).
-export_type([response/0]).
-export_type([t/0]).

-type opts() :: #{
    initial_access_token => binary() | undefined,
    request_opts => oidcc_http_util:request_opts()
}.
% Configure configuration loading / parsing
%
% <h2>Parameters</h2>
%
% <ul>
%   <li>`initial_access_token' - Access Token for registration</li>
%   <li>`request_opts' - config for HTTP request</li>
% </ul>

-type t() ::
    #oidcc_client_registration{
        redirect_uris :: [uri_string:uri_string()],
        response_types :: [binary()] | undefined,
        grant_types :: [binary()] | undefined,
        application_type :: web | native,
        contacts :: [binary()] | undefined,
        client_name :: binary() | undefined,
        logo_uri :: uri_string:uri_string() | undefined,
        client_uri :: uri_string:uri_string() | undefined,
        policy_uri :: uri_string:uri_string() | undefined,
        tos_uri :: uri_string:uri_string() | undefined,
        jwks :: jose_jwk:key() | undefined,
        jwks_uri :: uri_string:uri_string() | undefined,
        sector_identifier_uri :: uri_string:uri_string() | undefined,
        subject_type :: pairwise | public | undefined,
        id_token_signed_response_alg :: binary() | undefined,
        id_token_encrypted_response_alg :: binary() | undefined,
        id_token_encrypted_response_enc :: binary() | undefined,
        userinfo_signed_response_alg :: binary() | undefined,
        userinfo_encrypted_response_alg :: binary() | undefined,
        userinfo_encrypted_response_enc :: binary() | undefined,
        request_object_signing_alg :: binary() | undefined,
        request_object_encryption_alg :: binary() | undefined,
        request_object_encryption_enc :: binary() | undefined,
        token_endpoint_auth_method :: erlang:binary(),
        token_endpoint_auth_signing_alg :: binary() | undefined,
        default_max_age :: pos_integer() | undefined,
        require_auth_time :: boolean(),
        default_acr_values :: [binary()] | undefined,
        initiate_login_uri :: uri_string:uri_string() | undefined,
        request_uris :: [uri_string:uri_string()] | undefined,
        %% Unknown Fields
        extra_fields :: #{binary() => term()}
    }.
%% Record containing Client Registration Metadata
%%
%% See [https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata]
%%
%% All unrecognized fields are stored in `extra_fields'.

-type response() ::
    #oidcc_client_registration_response{
        client_id :: erlang:binary(),
        client_secret :: binary() | undefined,
        registration_access_token :: binary() | undefined,
        registration_client_uri :: uri_string:uri_string() | undefined,
        client_id_issued_at :: pos_integer() | undefined,
        client_secret_expires_at :: pos_integer() | undefined,
        %% Unknown Fields
        extra_fields :: #{binary() => term()}
    }.
%% Record containing Client Registration Response
%%
%% See [https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse]
%%
%% All unrecognized fields are stored in `extra_fields'.

-type error() ::
    registration_not_supported
    | invalid_content_type
    | oidcc_decode_util:error()
    | oidcc_http_util:error().

-telemetry_event(#{
    event => [oidcc, register_client, start],
    description => <<"Emitted at the start of registering the client">>,
    measurements => <<"#{system_time => non_neg_integer()}">>,
    metadata => <<"#{issuer => uri_string:uri_string()}">>
}).

-telemetry_event(#{
    event => [oidcc, register_client, stop],
    description => <<"Emitted at the end of registering the client">>,
    measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
    metadata => <<"#{issuer => uri_string:uri_string()}">>
}).

-telemetry_event(#{
    event => [oidcc, register_client, exception],
    description => <<"Emitted at the end of registering the client">>,
    measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
    metadata => <<"#{issuer => uri_string:uri_string()}">>
}).

%% @doc Register Client
%%
%% <h2>Examples</h2>
%%
%% ```
%% {ok, ProviderConfiguration} =
%%   oidcc_provider_configuration:load_configuration("https://your.issuer"),
%%
%% {ok, #oidcc_client_registration_response{
%%   client_id = ClientId,
%%   client_secret = ClientSecret
%% }} =
%%   oidcc_client_registration:register(
%%     ProviderConfiguration,
%%     #oidcc_client_registration{
%%       redirect_uris = ["https://your.application.com/oidcc/callback"]
%%     },
%%     #{initial_access_token => <<"optional token you got from the provider">>}
%%   ).
%% '''
%% @end
%% @since 3.0.0
-spec register(ProviderConfiguration, Registration, Opts) ->
    {ok, response()} | {error, error()}
when
    ProviderConfiguration :: oidcc_provider_configuration:t(),
    Registration :: t(),
    Opts :: opts().
register(#oidcc_provider_configuration{registration_endpoint = undefined}, _Registration, _Opts) ->
    {error, registration_not_supported};
register(
    #oidcc_provider_configuration{issuer = Issuer, registration_endpoint = RegistrationEndpoint},
    Registration,
    Opts
) ->
    RegistrationBody = encode(Registration),
    TelemetryOpts = #{topic => [oidcc, register_client], extra_meta => #{issuer => Issuer}},
    RequestOpts = maps:get(request_opts, Opts, #{}),
    Headers = case maps:get(initial_access_token, Opts, undefined) of
        undefined -> [];
        Token -> [{"authorization", ["Bearer ", Token]}]
    end,
    Request = {RegistrationEndpoint, Headers, "application/json", RegistrationBody},

    maybe
        {ok, {{json, ResponseMap}, _Headers}} ?= oidcc_http_util:request(post, Request, TelemetryOpts, RequestOpts),
        {ok, #oidcc_client_registration_response{} = Response} ?= decode_response(ResponseMap),
        {ok, Response}
    else
        {error, Reason} -> {error, Reason};
        {ok, {{_Format, _Body}, _}} -> {error, invalid_content_type}
    end.

-spec decode_response(Response) -> {ok, response()} | {error, error()} when Response :: map().
decode_response(Response) ->
    case
        oidcc_decode_util:extract(
            Response,
            [
                {required, client_id, fun oidcc_decode_util:parse_setting_binary/2},
                {optional, client_secret, undefined, fun oidcc_decode_util:parse_setting_binary/2},
                {optional, registration_access_token, undefined,
                    fun oidcc_decode_util:parse_setting_binary/2},
                {optional, registration_client_uri, undefined,
                    fun oidcc_decode_util:parse_setting_uri_https/2},
                {optional, client_id_issued_at, undefined,
                    fun oidcc_decode_util:parse_setting_number/2},
                {optional, client_secret_expires_at, undefined,
                    fun oidcc_decode_util:parse_setting_number/2}
            ],
            #{}
        )
    of
        {ok, {
            #{
                client_id := ClientId,
                client_secret := ClientSecret,
                registration_access_token := RegistrationAccessToken,
                registration_client_uri := RegistrationClientUri,
                client_id_issued_at := ClientIdIssuedAt,
                client_secret_expires_at := ClientSecretExpiresAt
            },
            ExtraFields
        }} ->
            {ok, #oidcc_client_registration_response{
                client_id = ClientId,
                client_secret = ClientSecret,
                registration_access_token = RegistrationAccessToken,
                registration_client_uri = RegistrationClientUri,
                client_id_issued_at = ClientIdIssuedAt,
                client_secret_expires_at = ClientSecretExpiresAt,
                extra_fields = ExtraFields
            }};
        {error, Reason} ->
            {error, Reason}
    end.

-spec encode(Metadata) -> binary() when Metadata :: t().
encode(#oidcc_client_registration{
    redirect_uris = RedirectUris,
    response_types = ResponseTypes,
    grant_types = GrantTypes,
    application_type = ApplicationType,
    contacts = Contacts,
    client_name = ClientName,
    logo_uri = LogoUri,
    client_uri = ClientUri,
    policy_uri = PolicyUri,
    tos_uri = TosUri,
    jwks = Jwks,
    sector_identifier_uri = SectorIdentifierUri,
    subject_type = SubjectType,
    id_token_signed_response_alg = IdTokenSignedResponseAlg,
    id_token_encrypted_response_alg = IdTokenencryptedResponseAlg,
    id_token_encrypted_response_enc = IdTokenEncryptedResponseEnc,
    userinfo_signed_response_alg = UserinfoSignedResponseAlg,
    userinfo_encrypted_response_alg = UserinfoEncryptedResponseAlg,
    userinfo_encrypted_response_enc = UserinfoEncryptedResponseEnc,
    request_object_signing_alg = RequestObjectSigningAlg,
    request_object_encryption_alg = RequestObjectEncryptionAlg,
    request_object_encryption_enc = RequestObjectEncryptionEnc,
    token_endpoint_auth_method = TokenEndpointAuthMethod,
    token_endpoint_auth_signing_alg = TokenEndpointAuthSigningAlg,
    default_max_age = DefaultMaxAge,
    require_auth_time = RequireAuthTime,
    default_acr_values = DefaultAcrValues,
    initiate_login_uri = InitiateLoginUri,
    request_uris = RequestUris,
    extra_fields = ExtraFields
}) ->
    Map0 = #{
        redirect_uris => RedirectUris,
        response_types => ResponseTypes,
        grant_types => GrantTypes,
        application_type => ApplicationType,
        contacts => Contacts,
        client_name => ClientName,
        logo_uri => LogoUri,
        client_uri => ClientUri,
        policy_uri => PolicyUri,
        tos_uri => TosUri,
        jwks =>
            case Jwks of
                undefined -> undefined;
                _ -> jose_jwk:to_map(Jwks)
            end,
        sector_identifier_uri => SectorIdentifierUri,
        subject_type => SubjectType,
        id_token_signed_response_alg => IdTokenSignedResponseAlg,
        id_token_encrypted_response_alg => IdTokenencryptedResponseAlg,
        id_token_encrypted_response_enc => IdTokenEncryptedResponseEnc,
        userinfo_signed_response_alg => UserinfoSignedResponseAlg,
        userinfo_encrypted_response_alg => UserinfoEncryptedResponseAlg,
        userinfo_encrypted_response_enc => UserinfoEncryptedResponseEnc,
        request_object_signing_alg => RequestObjectSigningAlg,
        request_object_encryption_alg => RequestObjectEncryptionAlg,
        request_object_encryption_enc => RequestObjectEncryptionEnc,
        token_endpoint_auth_method => TokenEndpointAuthMethod,
        token_endpoint_auth_signing_alg => TokenEndpointAuthSigningAlg,
        default_max_age => DefaultMaxAge,
        require_auth_time => RequireAuthTime,
        default_acr_values => DefaultAcrValues,
        initiate_login_uri => InitiateLoginUri,
        request_uris => RequestUris,
        extra_fields => ExtraFields
    },
    Map = maps:filter(
        fun
            (_Key, undefined) -> false;
            (_Key, _Value) -> true
        end,
        Map0
    ),
    jose:encode(Map).