%%%------------------------------------------------------------------- %% @doc Tooling to load and parse Openid Configuration %% %%

Records

%% %% To use the record, import the definition: %% %% ``` %% -include_lib(["oidcc/include/oidcc_provider_configuration.hrl"]). %% ''' %% %%

Telemetry

%% %% See {@link 'Elixir.Oidcc.ProviderConfiguration'} %% @end %% @since 3.0.0 %%%------------------------------------------------------------------- -module(oidcc_provider_configuration). -feature(maybe_expr, enable). -include("oidcc_provider_configuration.hrl"). -export([decode_configuration/1]). -export([decode_configuration/2]). -export([load_configuration/1]). -export([load_configuration/2]). -export([load_jwks/2]). -export_type([error/0]). -export_type([opts/0]). -export_type([quirks/0]). -export_type([t/0]). -type quirks() :: #{ allow_issuer_mismatch => boolean(), allow_unsafe_http => boolean() }. %% Allow Specification Non-compliance %% %%

Exceptions

%% %% -type opts() :: #{ fallback_expiry => timeout(), request_opts => oidcc_http_util:request_opts(), quirks => quirks() }. %% Configure configuration loading / parsing %% %%

Parameters

%% %% -type t() :: #oidcc_provider_configuration{ issuer :: uri_string:uri_string(), authorization_endpoint :: uri_string:uri_string(), token_endpoint :: uri_string:uri_string() | undefined, userinfo_endpoint :: uri_string:uri_string() | undefined, jwks_uri :: uri_string:uri_string() | undefined, registration_endpoint :: uri_string:uri_string() | undefined, scopes_supported :: [binary()] | undefined, response_types_supported :: [binary()], response_modes_supported :: [binary()], grant_types_supported :: [binary()], acr_values_supported :: [binary()] | undefined, subject_types_supported :: [pairwise | public], id_token_signing_alg_values_supported :: [binary()], id_token_encryption_alg_values_supported :: [binary()] | undefined, id_token_encryption_enc_values_supported :: [binary()] | undefined, userinfo_signing_alg_values_supported :: [binary()] | undefined, userinfo_encryption_alg_values_supported :: [binary()] | undefined, userinfo_encryption_enc_values_supported :: [binary()] | undefined, request_object_signing_alg_values_supported :: [binary()] | undefined, request_object_encryption_alg_values_supported :: [binary()] | undefined, request_object_encryption_enc_values_supported :: [binary()] | undefined, token_endpoint_auth_methods_supported :: [binary()], token_endpoint_auth_signing_alg_values_supported :: [binary()] | undefined, display_values_supported :: [binary()] | undefined, claim_types_supported :: [normal | aggregated | distributed], claims_supported :: [binary()] | undefined, service_documentation :: uri_string:uri_string() | undefined, claims_locales_supported :: [binary()] | undefined, ui_locales_supported :: [binary()] | undefined, claims_parameter_supported :: boolean(), request_parameter_supported :: boolean(), request_uri_parameter_supported :: boolean(), require_request_uri_registration :: boolean(), op_policy_uri :: uri_string:uri_string() | undefined, op_tos_uri :: uri_string:uri_string() | undefined, revocation_endpoint :: uri_string:uri_string() | undefined, revocation_endpoint_auth_methods_supported :: [binary()], revocation_endpoint_auth_signing_alg_values_supported :: [binary()] | undefined, introspection_endpoint :: uri_string:uri_string() | undefined, introspection_endpoint_auth_methods_supported :: [binary()], introspection_endpoint_auth_signing_alg_values_supported :: [binary()] | undefined, code_challenge_methods_supported :: [binary()] | undefined, end_session_endpoint :: uri_string:uri_string() | undefined, extra_fields :: #{binary() => term()} }. %% Record containing OpenID and OAuth 2.0 Configuration %% %% See [https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata], %% [https://datatracker.ietf.org/doc/html/draft-jones-oauth-discovery-01#section-4.1] and %% [https://openid.net/specs/openid-connect-rpinitiated-1_0.html#OPMetadata] %% %% All unrecognized fields are stored in `extra_fields'. -type error() :: invalid_content_type | {issuer_mismatch, Issuer :: binary()} | oidcc_decode_util:error() | oidcc_http_util:error(). -define(DEFAULT_CONFIG_EXPIRY, timer:minutes(15)). -telemetry_event(#{ event => [oidcc, load_configuration, start], description => <<"Emitted at the start of loading the provider configuration">>, measurements => <<"#{system_time => non_neg_integer()}">>, metadata => <<"#{issuer => uri_string:uri_string()}">> }). -telemetry_event(#{ event => [oidcc, load_configuration, stop], description => <<"Emitted at the end of loading the provider configuration">>, measurements => <<"#{duration => integer(), monotonic_time => integer()}">>, metadata => <<"#{issuer => uri_string:uri_string()}">> }). -telemetry_event(#{ event => [oidcc, load_configuration, exception], description => <<"Emitted at the end of loading the provider configuration">>, measurements => <<"#{duration => integer(), monotonic_time => integer()}">>, metadata => <<"#{issuer => uri_string:uri_string()}">> }). -telemetry_event(#{ event => [oidcc, load_jwks, start], description => <<"Emitted at the start of loading the provider jwks">>, measurements => <<"#{system_time => non_neg_integer()}">>, metadata => <<"#{jwks_uri => uri_string:uri_string()}">> }). -telemetry_event(#{ event => [oidcc, load_jwks, stop], description => <<"Emitted at the end of loading the provider jwks">>, measurements => <<"#{duration => integer(), monotonic_time => integer()}">>, metadata => <<"#{jwks_uri => uri_string:uri_string()}">> }). -telemetry_event(#{ event => [oidcc, load_jwks, exception], description => <<"Emitted at the end of loading the provider jwks">>, measurements => <<"#{duration => integer(), monotonic_time => integer()}">>, metadata => <<"#{jwks_uri => uri_string:uri_string()}">> }). %% @doc Load OpenID Configuration into a {@link oidcc_provider_configuration:t()} record %% %%

Examples

%% %% ``` %% {ok, #oidcc_provider_configuration{}} = %% oidcc_provider_configuration:load_configuration("https://accounts.google.com"). %% ''' %% @end %% @since 3.0.0 -spec load_configuration(Issuer, Opts) -> {ok, {Configuration :: t(), Expiry :: pos_integer()}} | {error, error()} when Issuer :: uri_string:uri_string(), Opts :: opts(). load_configuration(Issuer0, Opts) -> Issuer = binary:list_to_bin([Issuer0]), TelemetryOpts = #{topic => [oidcc, load_configuration], extra_meta => #{issuer => Issuer}}, RequestOpts = maps:get(request_opts, Opts, #{}), RequestUrl = uri_string:resolve(".well-known/openid-configuration", Issuer), Request = {RequestUrl, []}, Quirks = maps:get(quirks, Opts, #{}), AllowIssuerMismatch = maps:get(allow_issuer_mismatch, Quirks, false), maybe {ok, {{json, ConfigurationMap}, Headers}} ?= oidcc_http_util:request(get, Request, TelemetryOpts, RequestOpts), Expiry = headers_to_deadline(Headers, Opts), {ok, #oidcc_provider_configuration{issuer = ConfigIssuer} = Configuration} ?= decode_configuration(ConfigurationMap, #{quirks => Quirks}), case ConfigIssuer of Issuer -> {ok, {Configuration, Expiry}}; _DifferentIssuer when AllowIssuerMismatch -> {ok, {Configuration, Expiry}}; DifferentIssuer when not AllowIssuerMismatch -> {error, {issuer_mismatch, DifferentIssuer}} end else {error, Reason} -> {error, Reason}; {ok, {{_Format, _Body}, _Headers}} -> {error, invalid_content_type} end. %% @see load_configuration/2 %% @since 3.1.0 -spec load_configuration(Issuer) -> {ok, {Configuration :: t(), Expiry :: pos_integer()}} | {error, error()} when Issuer :: uri_string:uri_string(). load_configuration(Issuer) -> load_configuration(Issuer, #{}). %% @doc Load JWKs into a {@link jose_jwk:key()} record %% %%

Examples

%% %% ``` %% {ok, #jose_jwk{}} = %% oidcc_provider_configuration:load_jwks("https://www.googleapis.com/oauth2/v3/certs"). %% ''' %% @end %% @since 3.0.0 -spec load_jwks(JwksUri, Opts) -> {ok, {Jwks :: jose_jwk:key(), Expiry :: pos_integer()}} | {error, term()} when JwksUri :: uri_string:uri_string(), Opts :: opts(). load_jwks(JwksUri, Opts) -> TelemetryOpts = #{topic => [oidcc, load_jwks], extra_meta => #{jwks_uri => JwksUri}}, RequestOpts = maps:get(request_opts, Opts, #{}), maybe {ok, {{json, JwksBinary}, Headers}} ?= oidcc_http_util:request(get, {JwksUri, []}, TelemetryOpts, RequestOpts), Expiry = headers_to_deadline(Headers, Opts), Jwks = jose_jwk:from(JwksBinary), {ok, {Jwks, Expiry}} else {error, Reason} -> {error, Reason}; {ok, {{_Format, _Body}, _Headers}} -> {error, invalid_content_type} end. %% @doc Decode JSON into a {@link oidcc_provider_configuration:t()} record %% %%

Examples

%% %% ``` %% {ok, {{"HTTP/1.1",200,"OK"}, _Headers, Body}} = %% httpc:request("https://accounts.google.com/.well-known/openid-configuration"), %% %% {ok, DecodedJson} = your_json_lib:decode(Body), %% %% {ok, #oidcc_provider_configuration{}} = %% oidcc_provider_configuration:decode_configuration(DecodedJson). %% ''' %% @end %% @since 3.1.0 -spec decode_configuration(Configuration, Opts) -> {ok, t()} | {error, error()} when Configuration :: map(), Opts :: opts(). decode_configuration(Configuration, Opts) -> Quirks = maps:get(quirks, Opts, #{}), AllowUnsafeHttp = maps:get(allow_unsafe_http, Quirks, false), maybe {ok, { #{ issuer := Issuer, authorization_endpoint := AuthorizationEndpoint, authorization_endpoint := AuthorizationEndpoint, token_endpoint := TokenEndpoint, userinfo_endpoint := UserinfoEndpoint, jwks_uri := JwksUri, registration_endpoint := RegistrationEndpoint, scopes_supported := ScopesSupported, response_types_supported := ResponseTypesSupported, response_modes_supported := ResponseModesSupported, grant_types_supported := GrantTypesSupported, acr_values_supported := AcrValuesSupported, subject_types_supported := SubjectTypesSupported, id_token_signing_alg_values_supported := IdTokenSigningAlgValuesSupported, id_token_encryption_alg_values_supported := IdTokenEncryptionAlgValuesSupported, id_token_encryption_enc_values_supported := IdTokenEncryptionEncValuesSupported, userinfo_signing_alg_values_supported := UserinfoSigningAlgValuesSupported, userinfo_encryption_alg_values_supported := UserinfoEncryptionAlgValuesSupported, userinfo_encryption_enc_values_supported := UserinfoEncryptionEncValuesSupported, request_object_signing_alg_values_supported := RequestObjectSigningAlgValuesSupported, request_object_encryption_alg_values_supported := RequestObjectEncryptionAlgValuesSupported, request_object_encryption_enc_values_supported := RequestObjectEncryptionEncValuesSupported, token_endpoint_auth_methods_supported := TokenEndpointAuthMethodsSupported, token_endpoint_auth_signing_alg_values_supported := TokenEndpointAuthSigningAlgValuesSupported, display_values_supported := DisplayValuesSupported, claim_types_supported := ClaimTypesSupported, claims_supported := ClaimsSupported, service_documentation := ServiceDocumentation, claims_locales_supported := ClaimsLocalesSupported, ui_locales_supported := UiLocalesSupported, claims_parameter_supported := ClaimsParameterSupported, request_parameter_supported := RequestParameterSupported, request_uri_parameter_supported := RequestUriParameterSupported, require_request_uri_registration := RequireRequestUriRegistration, op_policy_uri := OpPolicyUri, op_tos_uri := OpTosUri, revocation_endpoint := RevocationEndpoint, revocation_endpoint_auth_methods_supported := RevocationEndpointAuthMethodsSupported, revocation_endpoint_auth_signing_alg_values_supported := RevocationEndpointAuthSigningAlgValuesSupported, introspection_endpoint := IntrospectionEndpoint, introspection_endpoint_auth_methods_supported := IntrospectionEndpointAuthMethodsSupported, introspection_endpoint_auth_signing_alg_values_supported := IntrospectionEndpointAuthSigningAlgValuesSupported, code_challenge_methods_supported := CodeChallengeMethodsSupported, end_session_endpoint := EndSessionEndpoint }, ExtraFields }} ?= oidcc_decode_util:extract( Configuration, [ {required, issuer, fun oidcc_decode_util:parse_setting_uri/2}, {required, authorization_endpoint, fun oidcc_decode_util:parse_setting_uri/2}, {optional, token_endpoint, undefined, fun oidcc_decode_util:parse_setting_uri/2}, {optional, userinfo_endpoint, undefined, case AllowUnsafeHttp of true -> fun oidcc_decode_util:parse_setting_uri/2; false -> fun oidcc_decode_util:parse_setting_uri_https/2 end}, {required, jwks_uri, fun oidcc_decode_util:parse_setting_uri/2}, {optional, registration_endpoint, undefined, fun oidcc_decode_util:parse_setting_uri/2}, {required, scopes_supported, fun parse_scopes_supported/2}, {required, response_types_supported, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, response_modes_supported, [<<"query">>, <<"fragment">>], fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, grant_types_supported, [<<"authorization_code">>, <<"implicit">>], fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, acr_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {required, subject_types_supported, fun parse_subject_types_supported/2}, {required, id_token_signing_alg_values_supported, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, id_token_encryption_alg_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, id_token_encryption_enc_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, userinfo_signing_alg_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, userinfo_encryption_alg_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, userinfo_encryption_enc_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, request_object_signing_alg_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, request_object_encryption_alg_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, request_object_encryption_enc_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, token_endpoint_auth_methods_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, token_endpoint_auth_signing_alg_values_supported, undefined, fun parse_token_signing_alg_values_no_none/2}, {optional, display_values_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, claim_types_supported, [normal], fun parse_claim_types_supported/2}, {optional, claims_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, service_documentation, undefined, fun oidcc_decode_util:parse_setting_uri/2}, {optional, claims_locales_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, ui_locales_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, claims_parameter_supported, false, fun oidcc_decode_util:parse_setting_boolean/2}, {optional, request_parameter_supported, false, fun oidcc_decode_util:parse_setting_boolean/2}, {optional, request_uri_parameter_supported, true, fun oidcc_decode_util:parse_setting_boolean/2}, {optional, require_request_uri_registration, false, fun oidcc_decode_util:parse_setting_boolean/2}, {optional, op_policy_uri, undefined, fun oidcc_decode_util:parse_setting_uri/2}, {optional, op_tos_uri, undefined, fun oidcc_decode_util:parse_setting_uri/2}, {optional, revocation_endpoint, undefined, fun oidcc_decode_util:parse_setting_uri/2}, {optional, revocation_endpoint_auth_methods_supported, [<<"client_secret_basic">>], fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, revocation_endpoint_auth_signing_alg_values_supported, undefined, fun parse_token_signing_alg_values_no_none/2}, {optional, introspection_endpoint, undefined, fun oidcc_decode_util:parse_setting_uri/2}, {optional, introspection_endpoint_auth_methods_supported, [<<"client_secret_basic">>], fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, introspection_endpoint_auth_signing_alg_values_supported, undefined, fun parse_token_signing_alg_values_no_none/2}, {optional, code_challenge_methods_supported, undefined, fun oidcc_decode_util:parse_setting_binary_list/2}, {optional, end_session_endpoint, undefined, case AllowUnsafeHttp of true -> fun oidcc_decode_util:parse_setting_uri/2; false -> fun oidcc_decode_util:parse_setting_uri_https/2 end} ], #{} ), {ok, #oidcc_provider_configuration{ issuer = Issuer, authorization_endpoint = AuthorizationEndpoint, token_endpoint = TokenEndpoint, userinfo_endpoint = UserinfoEndpoint, jwks_uri = JwksUri, registration_endpoint = RegistrationEndpoint, scopes_supported = ScopesSupported, response_types_supported = ResponseTypesSupported, response_modes_supported = ResponseModesSupported, grant_types_supported = GrantTypesSupported, acr_values_supported = AcrValuesSupported, subject_types_supported = SubjectTypesSupported, id_token_signing_alg_values_supported = IdTokenSigningAlgValuesSupported, id_token_encryption_alg_values_supported = IdTokenEncryptionAlgValuesSupported, id_token_encryption_enc_values_supported = IdTokenEncryptionEncValuesSupported, userinfo_signing_alg_values_supported = UserinfoSigningAlgValuesSupported, userinfo_encryption_alg_values_supported = UserinfoEncryptionAlgValuesSupported, userinfo_encryption_enc_values_supported = UserinfoEncryptionEncValuesSupported, request_object_signing_alg_values_supported = RequestObjectSigningAlgValuesSupported, request_object_encryption_alg_values_supported = RequestObjectEncryptionAlgValuesSupported, request_object_encryption_enc_values_supported = RequestObjectEncryptionEncValuesSupported, token_endpoint_auth_methods_supported = TokenEndpointAuthMethodsSupported, token_endpoint_auth_signing_alg_values_supported = TokenEndpointAuthSigningAlgValuesSupported, display_values_supported = DisplayValuesSupported, claim_types_supported = ClaimTypesSupported, claims_supported = ClaimsSupported, service_documentation = ServiceDocumentation, claims_locales_supported = ClaimsLocalesSupported, ui_locales_supported = UiLocalesSupported, claims_parameter_supported = ClaimsParameterSupported, request_parameter_supported = RequestParameterSupported, request_uri_parameter_supported = RequestUriParameterSupported, require_request_uri_registration = RequireRequestUriRegistration, op_policy_uri = OpPolicyUri, op_tos_uri = OpTosUri, revocation_endpoint = RevocationEndpoint, revocation_endpoint_auth_methods_supported = RevocationEndpointAuthMethodsSupported, revocation_endpoint_auth_signing_alg_values_supported = RevocationEndpointAuthSigningAlgValuesSupported, introspection_endpoint = IntrospectionEndpoint, introspection_endpoint_auth_methods_supported = IntrospectionEndpointAuthMethodsSupported, introspection_endpoint_auth_signing_alg_values_supported = IntrospectionEndpointAuthSigningAlgValuesSupported, code_challenge_methods_supported = CodeChallengeMethodsSupported, end_session_endpoint = EndSessionEndpoint, extra_fields = ExtraFields }} end. %% @see decode_configuration/2 %% @since 3.0.0 -spec decode_configuration(Configuration) -> {ok, t()} | {error, error()} when Configuration :: map(). decode_configuration(Configuration) -> decode_configuration(Configuration, #{}). -spec headers_to_deadline(Headers, Opts) -> pos_integer() when Headers :: [{Header :: binary(), Value :: binary()}], Opts :: opts(). headers_to_deadline(Headers, Opts) -> DefaultExpiry = maps:get(fallback_expiry, Opts, ?DEFAULT_CONFIG_EXPIRY), case proplists:lookup("cache-control", Headers) of {"cache-control", Cache} -> try cache_deadline(Cache, DefaultExpiry) catch _:_ -> DefaultExpiry end; none -> DefaultExpiry end. -spec cache_deadline(Cache :: iodata(), Fallback :: pos_integer()) -> pos_integer(). cache_deadline(Cache, Fallback) -> Entries = binary:split(iolist_to_binary(Cache), [<<",">>, <<"=">>, <<" ">>], [global, trim_all]), MaxAge = fun (<<"0">>, Res) -> Res; (Entry, true) -> erlang:convert_time_unit(binary_to_integer(Entry), second, millisecond); (<<"max-age">>, _) -> true; (_, Res) -> Res end, lists:foldl(MaxAge, Fallback, Entries). -spec parse_scopes_supported(Setting :: term(), Field :: atom()) -> {ok, [binary()]} | {error, error()}. parse_scopes_supported(Setting, Field) -> case oidcc_decode_util:parse_setting_binary_list(Setting, Field) of {ok, Scopes} -> case lists:member(<<"openid">>, Scopes) of true -> {ok, Scopes}; false -> {error, {invalid_config_property, {scopes_including_openid, Field}}} end; {error, Reason} -> {error, Reason} end. -spec parse_subject_types_supported(Setting :: term(), Field :: atom()) -> {ok, [binary()]} | {error, error()}. parse_subject_types_supported(Setting, Field) -> oidcc_decode_util:parse_setting_list_enum( Setting, Field, fun (<<"pairwise">>) -> {ok, pairwise}; (<<"public">>) -> {ok, public}; (_SubjectType) -> error end ). -spec parse_token_signing_alg_values_no_none(Setting :: term(), Field :: atom()) -> {ok, [binary()]} | {error, error()}. parse_token_signing_alg_values_no_none(Setting, Field) -> case oidcc_decode_util:parse_setting_binary_list(Setting, Field) of {ok, SigningAlgValues} -> case lists:any( fun (<<"none">>) -> true; (_) -> false end, SigningAlgValues ) of false -> {ok, SigningAlgValues}; true -> {error, {invalid_config_property, {alg_no_none, Field}}} end; {error, Reason} -> {error, Reason} end. -spec parse_claim_types_supported(Setting :: term(), Field :: atom()) -> {ok, [binary()]} | {error, error()}. parse_claim_types_supported(Setting, Field) -> oidcc_decode_util:parse_setting_list_enum( Setting, Field, fun (<<"normal">>) -> {ok, normal}; (<<"aggregated">>) -> {ok, aggregated}; (<<"distributed">>) -> {ok, distributed}; (_ClaimType) -> error end ).