diff --git a/README.md b/README.md index d4eb447..3b6a989 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,9 @@ The refactoring for `v3` and the certification is funded as an * Logout * [RP-Initiated](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) * [Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449) +* Profiles + * [FAPI 2.0 Security Profile](https://openid.bitbucket.io/fapi/fapi-2_0-security-profile.html) + * [FAPI 2.0 Message Signing](https://openid.bitbucket.io/fapi/fapi-2_0-message-signing.html) ## Setup diff --git a/include/oidcc_provider_configuration.hrl b/include/oidcc_provider_configuration.hrl index 137ce0a..0265523 100644 --- a/include/oidcc_provider_configuration.hrl +++ b/include/oidcc_provider_configuration.hrl @@ -107,6 +107,8 @@ authorization_response_iss_parameter_supported = false :: boolean(), %% OAuth 2.0 Demonstrating Proof of Possession (DPoP) dpop_signing_alg_values_supported = undefined :: [binary()] | undefined, + %% RFC 9101 The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR) + require_signed_request_object = false :: boolean(), %% Unknown Fields extra_fields = #{} :: #{binary() => term()} } diff --git a/lib/oidcc/client_context.ex b/lib/oidcc/client_context.ex index a4a4bfa..cf147d8 100644 --- a/lib/oidcc/client_context.ex +++ b/lib/oidcc/client_context.ex @@ -139,6 +139,45 @@ defmodule Oidcc.ClientContext do |> record_to_struct() end + @doc """ + Apply OpenID Connect / OAuth2 Profiles to the context + + See `:oidcc_client_context.apply_profiles/2` for more. + + ## Examples + + iex> {:ok, _pid} = + ...> Oidcc.ProviderConfiguration.Worker.start_link(%{ + ...> issuer: "https://accounts.google.com", + ...> name: __MODULE__.GoogleConfigProvider + ...> }) + ...> + ...> {:ok, client_context} = + ...> Oidcc.ClientContext.from_configuration_worker( + ...> __MODULE__.GoogleConfigProvider, + ...> "client_id", + ...> "client_Secret" + ...> ) + ...> + ...> {:ok, %Oidcc.ClientContext{}, %{}} = + ...> Oidcc.ClientContext.apply_profiles( + ...> client_context, + ...> %{profiles: [:fapi2_message_signing]} + ...> ) + """ + @doc since: "3.2.0" + @spec apply_profiles(t(), :oidcc_profile.opts()) :: + {:ok, t(), :oidcc_profile.opts_no_profiles()} | {:error, :oidcc_client_context.error()} + def apply_profiles(client_context, opts) do + case :oidcc_client_context.apply_profiles(struct_to_record(client_context), opts) do + {:ok, context_record, opts} -> + {:ok, record_to_struct(context_record), opts} + + {:error, reason} -> + {:error, reason} + end + end + @impl Oidcc.RecordStruct def record_to_struct(record) do record diff --git a/lib/oidcc/provider_configuration.ex b/lib/oidcc/provider_configuration.ex index 8bb22ec..0d3e8d5 100644 --- a/lib/oidcc/provider_configuration.ex +++ b/lib/oidcc/provider_configuration.ex @@ -117,6 +117,7 @@ defmodule Oidcc.ProviderConfiguration do authorization_encryption_alg_values_supported: [String.t()] | :undefined, authorization_encryption_enc_values_supported: [String.t()] | :undefined, dpop_signing_alg_values_supported: [String.t()] | :undefined, + require_signed_request_object: boolean(), extra_fields: %{String.t() => term()} } diff --git a/priv/test/fixtures/fapi2-metadata.json b/priv/test/fixtures/fapi2-metadata.json new file mode 100644 index 0000000..269abd9 --- /dev/null +++ b/priv/test/fixtures/fapi2-metadata.json @@ -0,0 +1,106 @@ +{ + "issuer": "https://my.provider", + "authorization_endpoint": "https://my.provider/auth", + "registration_endpoint": "https://my.provider/register", + "device_authorization_endpoint": "https://my.provider/device/code", + "token_endpoint": "https://my.provider/token", + "introspection_endpoint": "https://my.provider/introspection", + "userinfo_endpoint": "https://my.provider/userinfo", + "revocation_endpoint": "https://my.provider/revoke", + "jwks_uri": "https://my.provider/jwks", + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "none", + "HS256", + "RS256", + "EdDSA" + ], + "scopes_supported": [ + "openid", + "email", + "profile" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic", + "private_key_jwt", + "unsupporeted_auth" + ], + "claims_supported": [ + "aud", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "iss", + "locale", + "name", + "picture", + "sub" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ], + "userinfo_signing_alg_values_supported": [ + "none", + "HS256", + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES256K", + "ES384", + "ES512", + "EdDSA" + ], + "userinfo_encryption_alg_values_supported": [ + "RSA1_5", + "RSA-OAEP", + "RSA-OAEP-256", + "RSA-OAEP-384", + "RSA-OAEP-512", + "ECDH-ES", + "ECDH-ES+A128KW", + "ECDH-ES+A192KW", + "ECDH-ES+A256KW", + "A128KW", + "A192KW", + "A256KW", + "A128GCMKW", + "A192GCMKW", + "A256GCMKW", + "dir" + ], + "userinfo_encryption_enc_values_supported": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ] +} diff --git a/priv/test/fixtures/jwk-ed25519.pem b/priv/test/fixtures/jwk-ed25519.pem new file mode 100644 index 0000000..0993f81 --- /dev/null +++ b/priv/test/fixtures/jwk-ed25519.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIK2JHugMJkquFakgWVf2McF16O6Vkf3rA3PAcutRFmbK +-----END PRIVATE KEY----- diff --git a/src/oidcc.erl b/src/oidcc.erl index 50c8719..1b7736b 100644 --- a/src/oidcc.erl +++ b/src/oidcc.erl @@ -69,15 +69,16 @@ when Opts :: oidcc_authorization:opts() | oidcc_client_context:opts(), Uri :: uri_string:uri_string(). create_redirect_url(ProviderConfigurationWorkerName, ClientId, ClientSecret, Opts) -> - {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts), + {ClientContextOpts, OtherOpts0} = extract_client_context_opts(Opts), maybe - {ok, ClientContext} ?= + {ok, ClientContext0} ?= oidcc_client_context:from_configuration_worker( ProviderConfigurationWorkerName, ClientId, ClientSecret, ClientContextOpts ), + {ok, ClientContext, OtherOpts} = oidcc_profile:apply_profiles(ClientContext0, OtherOpts0), oidcc_authorization:create_redirect_url(ClientContext, OtherOpts) end. @@ -128,16 +129,19 @@ retrieve_token( {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts), RefreshJwksFun = oidcc_jwt_util:refresh_jwks_fun(ProviderConfigurationWorkerName), - OptsWithRefresh = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts), + OptsWithRefresh0 = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts), maybe - {ok, ClientContext} ?= + {ok, ClientContext0} ?= oidcc_client_context:from_configuration_worker( ProviderConfigurationWorkerName, ClientId, ClientSecret, ClientContextOpts ), + {ok, ClientContext, OptsWithRefresh} = oidcc_profile:apply_profiles( + ClientContext0, OptsWithRefresh0 + ), oidcc_token:retrieve(AuthCode, ClientContext, OptsWithRefresh) end. @@ -190,16 +194,17 @@ retrieve_userinfo( ClientSecret, Opts ) -> - {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts), + {ClientContextOpts, OtherOpts0} = extract_client_context_opts(Opts), maybe - {ok, ClientContext} ?= + {ok, ClientContext0} ?= oidcc_client_context:from_configuration_worker( ProviderConfigurationWorkerName, ClientId, ClientSecret, ClientContextOpts ), + {ok, ClientContext, OtherOpts} = oidcc_profile:apply_profiles(ClientContext0, OtherOpts0), oidcc_userinfo:retrieve(Token, ClientContext, OtherOpts) end. @@ -260,16 +265,19 @@ refresh_token( {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts), RefreshJwksFun = oidcc_jwt_util:refresh_jwks_fun(ProviderConfigurationWorkerName), - OptsWithRefresh = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts), + OptsWithRefresh0 = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts), maybe - {ok, ClientContext} ?= + {ok, ClientContext0} ?= oidcc_client_context:from_configuration_worker( ProviderConfigurationWorkerName, ClientId, ClientSecret, ClientContextOpts ), + {ok, ClientContext, OptsWithRefresh} = oidcc_profile:apply_profiles( + ClientContext0, OptsWithRefresh0 + ), oidcc_token:refresh(RefreshToken, ClientContext, OptsWithRefresh) end. @@ -314,16 +322,17 @@ introspect_token( ClientSecret, Opts ) -> - {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts), + {ClientContextOpts, OtherOpts0} = extract_client_context_opts(Opts), maybe - {ok, ClientContext} ?= + {ok, ClientContext0} ?= oidcc_client_context:from_configuration_worker( ProviderConfigurationWorkerName, ClientId, ClientSecret, ClientContextOpts ), + {ok, ClientContext, OtherOpts} = oidcc_profile:apply_profiles(ClientContext0, OtherOpts0), oidcc_token_introspection:introspect(Token, ClientContext, OtherOpts) end. @@ -371,16 +380,19 @@ jwt_profile_token(Subject, ProviderConfigurationWorkerName, ClientId, ClientSecr {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts), RefreshJwksFun = oidcc_jwt_util:refresh_jwks_fun(ProviderConfigurationWorkerName), - OptsWithRefresh = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts), + OptsWithRefresh0 = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts), maybe - {ok, ClientContext} ?= + {ok, ClientContext0} ?= oidcc_client_context:from_configuration_worker( ProviderConfigurationWorkerName, ClientId, ClientSecret, ClientContextOpts ), + {ok, ClientContext, OptsWithRefresh} = oidcc_profile:apply_profiles( + ClientContext0, OptsWithRefresh0 + ), oidcc_token:jwt_profile(Subject, ClientContext, Jwk, OptsWithRefresh) end. @@ -415,16 +427,19 @@ client_credentials_token(ProviderConfigurationWorkerName, ClientId, ClientSecret {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts), RefreshJwksFun = oidcc_jwt_util:refresh_jwks_fun(ProviderConfigurationWorkerName), - OptsWithRefresh = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts), + OptsWithRefresh0 = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts), maybe - {ok, ClientContext} ?= + {ok, ClientContext0} ?= oidcc_client_context:from_configuration_worker( ProviderConfigurationWorkerName, ClientId, ClientSecret, ClientContextOpts ), + {ok, ClientContext, OptsWithRefresh} = oidcc_profile:apply_profiles( + ClientContext0, OptsWithRefresh0 + ), oidcc_token:client_credentials(ClientContext, OptsWithRefresh) end. @@ -464,16 +479,17 @@ when ClientId :: binary(), Opts :: oidcc_logout:initiate_url_opts() | oidcc_client_context:unauthenticated_opts(). initiate_logout_url(Token, ProviderConfigurationWorkerName, ClientId, Opts) -> - {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts), + {ClientContextOpts, OtherOpts0} = extract_client_context_opts(Opts), maybe - {ok, ClientContext} ?= + {ok, ClientContext0} ?= oidcc_client_context:from_configuration_worker( ProviderConfigurationWorkerName, ClientId, unauthenticated, ClientContextOpts ), + {ok, ClientContext, OtherOpts} = oidcc_profile:apply_profiles(ClientContext0, OtherOpts0), oidcc_logout:initiate_url(Token, ClientContext, OtherOpts) end. diff --git a/src/oidcc_authorization.erl b/src/oidcc_authorization.erl index 747cabd..8dd8063 100644 --- a/src/oidcc_authorization.erl +++ b/src/oidcc_authorization.erl @@ -23,7 +23,8 @@ state => binary(), nonce => binary(), pkce_verifier => binary(), - redirect_uri := uri_string:uri_string(), + require_pkce => boolean(), + redirect_uri => uri_string:uri_string(), url_extension => oidcc_http_util:query_params() }. %% Configure authorization redirect url @@ -38,12 +39,18 @@ %%
  • `nonce' - nonce to pass to the provider
  • %%
  • `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
  • %% -type error() :: - {grant_type_not_supported, authorization_code} | par_required | oidcc_http_util:error(). + {grant_type_not_supported, authorization_code} + | par_required + | request_object_required + | pkce_verifier_required + | no_supported_code_challenge + | oidcc_http_util:error(). %% @doc %% Create Auth Redirect URL @@ -104,31 +111,35 @@ redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opt ], 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_verifier, Opts, none), QueryParams2, ClientContext - ), - QueryParams4 = oidcc_scope:query_append_scope( - maps:get(scopes, Opts, [openid]), QueryParams3 - ), - QueryParams5 = maybe_append_dpop_jkt(QueryParams4, ClientContext), - QueryParams6 = attempt_request_object(QueryParams5, ClientContext), - attempt_par(QueryParams6, ClientContext, Opts). + maybe + {ok, QueryParams3} ?= + append_code_challenge( + Opts, QueryParams2, ClientContext + ), + QueryParams4 = oidcc_scope:query_append_scope( + maps:get(scopes, Opts, [openid]), QueryParams3 + ), + QueryParams5 = maybe_append_dpop_jkt(QueryParams4, ClientContext), + {ok, QueryParams6} ?= attempt_request_object(QueryParams5, ClientContext), + attempt_par(QueryParams6, ClientContext, Opts) + end. --spec append_code_challenge(PkceVerifier, QueryParams, ClientContext) -> - oidcc_http_util:query_params() +-spec append_code_challenge(Opts, QueryParams, ClientContext) -> + {ok, oidcc_http_util:query_params()} | {error, error()} when - PkceVerifier :: binary() | none, + Opts :: opts(), QueryParams :: oidcc_http_util:query_params(), ClientContext :: oidcc_client_context:t(). -append_code_challenge(none, QueryParams, _ClientContext) -> - QueryParams; -append_code_challenge(CodeVerifier, QueryParams, ClientContext) -> +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 =:= true -> + {error, no_supported_code_challenge}; undefined -> - QueryParams; + {ok, QueryParams}; Methods when is_list(Methods) -> case { @@ -140,21 +151,27 @@ append_code_challenge(CodeVerifier, QueryParams, ClientContext) -> 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 =:= true -> + {error, no_supported_code_challenge}; {false, false} -> - QueryParams + {ok, QueryParams} end - 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(), @@ -185,15 +202,11 @@ maybe_append_dpop_jkt( maybe_append_dpop_jkt(QueryParams, _ClientContext) -> QueryParams. --spec attempt_request_object(QueryParams, ClientContext) -> QueryParams when +-spec attempt_request_object(QueryParams, ClientContext) -> + {ok, QueryParams} | {error, error()} +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_secret = unauthenticated}) -> - QueryParams; attempt_request_object(QueryParams, #oidcc_client_context{ client_id = ClientId, client_secret = ClientSecret, @@ -201,12 +214,13 @@ attempt_request_object(QueryParams, #oidcc_client_context{ 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 -}) -> +}) when ClientSecret =/= unauthenticated -> SigningAlgSupported = case SigningAlgSupported0 of undefined -> []; @@ -264,8 +278,10 @@ attempt_request_object(QueryParams, #oidcc_client_context{ 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 =:= true -> + {error, request_object_required}; {error, no_supported_alg_or_key} -> - QueryParams; + {ok, QueryParams}; {ok, SignedRequestObject} -> case oidcc_jwt_util:encrypt( @@ -276,11 +292,17 @@ attempt_request_object(QueryParams, #oidcc_client_context{ ) of {ok, EncryptedRequestObject} -> - [{<<"request">>, EncryptedRequestObject} | essential_params(QueryParams)]; + {ok, [{<<"request">>, EncryptedRequestObject} | essential_params(QueryParams)]}; {error, no_supported_alg_or_key} -> - [{<<"request">>, SignedRequestObject} | essential_params(QueryParams)] + {ok, [{<<"request">>, SignedRequestObject} | essential_params(QueryParams)]} end - end. + end; +attempt_request_object(_QueryParams, #oidcc_client_context{ + provider_configuration = #oidcc_provider_configuration{require_signed_request_object = true} +}) -> + {error, request_object_required}; +attempt_request_object(QueryParams, _ClientContext) -> + {ok, QueryParams}. -spec attempt_par(QueryParams, ClientContext, Opts) -> {ok, QueryParams} | {error, error()} diff --git a/src/oidcc_client_context.erl b/src/oidcc_client_context.erl index 225f389..54f5e92 100644 --- a/src/oidcc_client_context.erl +++ b/src/oidcc_client_context.erl @@ -34,6 +34,7 @@ -export([from_configuration_worker/4]). -export([from_manual/4]). -export([from_manual/5]). +-export([apply_profiles/2]). -type t() :: authenticated_t() | unauthenticated_t(). @@ -227,3 +228,37 @@ from_manual( client_secret = ClientSecret, client_jwks = maps:get(client_jwks, Opts, none) }. + +%% @doc Apply OpenID Connect / OAuth2 Profiles to the context +%% +%% Currently, the only supported profiles are: +%% - `fapi2_security_profile' - https://openid.bitbucket.io/fapi/fapi-2_0-security-profile.html +%% - `fapi2_message_signing' - https://openid.bitbucket.io/fapi/fapi-2_0-message-signing.html +%% +%% It returns an updated `#oidcc_client_context{}' record and a map of options to +%% be merged into the `oidcc_authorization` and `oidcc_token` functions. +%% +%%

    Examples

    +%% +%% ``` +%% ClientContext = #oidcc_client_context{} = oidcc_client_context:from_...(...), +%% +%% {#oidcc_client_context{} = ClientContext1, Opts} = oidcc_client_context:apply_profiles( +%% ClientContext, +%% #{ +%% profiles => [fapi2_message_signing] +%% }), +%% +%% {ok, Uri} = oidcc_authorization:create_redirect_uri( +%% ClientContext1, +%% maps:merge(Opts, #{...}) +%% ). +%% ''' +%% @end +%% @since 3.2.0 +-spec apply_profiles(ClientContext, oidcc_profile:opts()) -> + {ok, ClientContext, oidcc_profile:opts_no_profiles()} | {error, oidcc_profile:error()} +when + ClientContext :: oidcc_client_context:t(). +apply_profiles(ClientContext, Opts) -> + oidcc_profile:apply_profiles(ClientContext, Opts). diff --git a/src/oidcc_profile.erl b/src/oidcc_profile.erl new file mode 100644 index 0000000..fc5a9bf --- /dev/null +++ b/src/oidcc_profile.erl @@ -0,0 +1,186 @@ +%%%------------------------------------------------------------------- +%% @doc OpenID Profile Utilities +%% @end +%% @since 3.2.0 +%%%------------------------------------------------------------------- +-module(oidcc_profile). + +-feature(maybe_expr, enable). + +-include("oidcc_client_context.hrl"). +-include("oidcc_provider_configuration.hrl"). + +-export([apply_profiles/2]). + +-export_type([profile/0]). +-export_type([opts/0]). +-export_type([opts_no_profiles/0]). +-export_type([error/0]). + +-type profile() :: fapi2_security_profile | fapi2_message_signing. +-type opts() :: #{ + profiles => [profile()], + require_pkce => boolean(), + trusted_audiences => [binary()] | any, + preferred_auth_methods => [oidcc_auth_util:auth_method()] +}. +-type opts_no_profiles() :: #{ + require_pkce => boolean(), + trusted_audiences => [binary()] | any, + preferred_auth_methods => [oidcc_auth_util:auth_method()] +}. +-type error() :: {unknown_profile, atom()}. + +%% @private +-spec apply_profiles(ClientContext, opts()) -> + {ok, ClientContext, opts_no_profiles()} | {error, error()} +when + ClientContext :: oidcc_client_context:t(). +apply_profiles( + #oidcc_client_context{} = ClientContext0, + #{profiles := [fapi2_security_profile | RestProfiles]} = Opts0 +) -> + %% FAPI2 Security Profile + %% - https://openid.bitbucket.io/fapi/fapi-2_0-security-profile.html + {ClientContext1, Opts1} = enforce_s256_pkce(ClientContext0, Opts0), + ClientContext2 = limit_response_types([<<"code">>], ClientContext1), + ClientContext3 = enforce_par(ClientContext2), + ClientContext4 = enforce_iss_parameter(ClientContext3), + ClientContext = limit_signing_alg_values( + [ + <<"PS256">>, + <<"PS384">>, + <<"PS512">>, + <<"ES256">>, + <<"ES384">>, + <<"ES512">>, + <<"EdDSA">> + ], + ClientContext4 + ), + Opts2 = Opts1#{profiles => RestProfiles}, + Opts3 = map_put_new(trusted_audiences, [], Opts2), + %% TODO include tls_client_auth">> here when it's supported by the library. + Opts = map_put_new(preferred_auth_methods, [private_key_jwt], Opts3), + apply_profiles(ClientContext, Opts); +apply_profiles( + #oidcc_client_context{} = ClientContext, + #{profiles := [fapi2_message_signing | RestProfiles]} = Opts0 +) -> + %% FAPI2 Message Signing: + %% - https://openid.bitbucket.io/fapi/fapi-2_0-message- signing.html + + %% TODO force require_signed_request_object once the conformance suite can + %% validate it (currently, the suite fails if this is enabled) + %% TODO limit response_mode_supported to [<<"jwt">>] once JARM is supported. + %% This is required by the spec, but not currently by the conformance suite. + %% TODO require signed token introspection responses + + %% Also require everything from FAPI2 Security Profile + Opts = Opts0#{profiles => [fapi2_security_profile | RestProfiles]}, + apply_profiles(ClientContext, Opts); +apply_profiles(#oidcc_client_context{}, #{profiles := [UnknownProfile | _]}) -> + {error, {unknown_profile, UnknownProfile}}; +apply_profiles(#oidcc_client_context{} = ClientContext, #{profiles := []} = Opts0) -> + Opts = maps:remove(profiles, Opts0), + apply_profiles(ClientContext, Opts); +apply_profiles(#oidcc_client_context{} = ClientContext, #{} = Opts) -> + {ok, ClientContext, Opts}. + +enforce_s256_pkce(ClientContext0, Opts0) -> + #oidcc_client_context{ + provider_configuration = + ProviderConfiguration0 = #oidcc_provider_configuration{ + code_challenge_methods_supported = CodeChallengeMethodsSupported + } + } = ClientContext0, + ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{ + code_challenge_methods_supported = limit_values([<<"S256">>], CodeChallengeMethodsSupported) + }, + ClientContext = ClientContext0#oidcc_client_context{ + provider_configuration = ProviderConfiguration + }, + Opts = Opts0#{require_pkce => true}, + {ClientContext, Opts}. + +limit_response_types(Types, ClientContext0) -> + #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0, + #oidcc_provider_configuration{ + response_types_supported = ResponseTypes + } = ProviderConfiguration0, + ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{ + response_types_supported = limit_values(Types, ResponseTypes) + }, + ClientContext = ClientContext0#oidcc_client_context{ + provider_configuration = ProviderConfiguration + }, + ClientContext. + +enforce_par(ClientContext0) -> + #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0, + ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{ + require_pushed_authorization_requests = true + }, + ClientContext = ClientContext0#oidcc_client_context{ + provider_configuration = ProviderConfiguration + }, + ClientContext. + +enforce_iss_parameter(ClientContext0) -> + #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0, + ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{ + authorization_response_iss_parameter_supported = true + }, + ClientContext = ClientContext0#oidcc_client_context{ + provider_configuration = ProviderConfiguration + }, + ClientContext. + +limit_signing_alg_values(AlgSupported, ClientContext0) -> + #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0, + #oidcc_provider_configuration{ + id_token_signing_alg_values_supported = IdAlg, + userinfo_signing_alg_values_supported = UserinfoAlg, + request_object_signing_alg_values_supported = RequestObjectAlg, + token_endpoint_auth_signing_alg_values_supported = TokenAlg, + revocation_endpoint_auth_signing_alg_values_supported = RevocationAlg, + introspection_endpoint_auth_signing_alg_values_supported = IntrospectionAlg, + authorization_signing_alg_values_supported = AuthorizationAlg, + dpop_signing_alg_values_supported = DpopAlg + } = ProviderConfiguration0, + ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{ + id_token_signing_alg_values_supported = limit_values(AlgSupported, IdAlg), + userinfo_signing_alg_values_supported = limit_values(AlgSupported, UserinfoAlg), + request_object_signing_alg_values_supported = limit_values(AlgSupported, RequestObjectAlg), + token_endpoint_auth_signing_alg_values_supported = limit_values(AlgSupported, TokenAlg), + revocation_endpoint_auth_signing_alg_values_supported = limit_values( + AlgSupported, RevocationAlg + ), + introspection_endpoint_auth_signing_alg_values_supported = limit_values( + AlgSupported, IntrospectionAlg + ), + authorization_signing_alg_values_supported = limit_values(AlgSupported, AuthorizationAlg), + dpop_signing_alg_values_supported = limit_values(AlgSupported, DpopAlg) + }, + ClientContext = ClientContext0#oidcc_client_context{ + provider_configuration = ProviderConfiguration + }, + ClientContext. + +limit_values(_Limit, undefined) -> + undefined; +limit_values(Limit, Values) -> + case [V || V <- Values, lists:member(V, Limit)] of + [] -> + undefined; + Filtered -> + Filtered + end. + +map_put_new(Key, Value, Map) -> + case Map of + #{Key := _} -> + Map; + _ -> + Map#{Key => Value} + end. diff --git a/src/oidcc_provider_configuration.erl b/src/oidcc_provider_configuration.erl index 6915568..060f276 100644 --- a/src/oidcc_provider_configuration.erl +++ b/src/oidcc_provider_configuration.erl @@ -123,6 +123,7 @@ authorization_encryption_enc_values_supported :: [binary()] | undefined, authorization_response_iss_parameter_supported :: boolean(), dpop_signing_alg_values_supported :: [binary()] | undefined, + require_signed_request_object :: boolean(), extra_fields :: #{binary() => term()} }. %% Record containing OpenID and OAuth 2.0 Configuration @@ -357,7 +358,8 @@ decode_configuration(Configuration0, Opts) -> AuthorizationEncryptionEncValuesSupported, authorization_response_iss_parameter_supported := AuthorizationResponseIssParameterSupported, - dpop_signing_alg_values_supported := DpopSigningAlgValuesSupported + dpop_signing_alg_values_supported := DpopSigningAlgValuesSupported, + require_signed_request_object := RequireSignedRequestObject }, ExtraFields }} ?= @@ -466,7 +468,9 @@ decode_configuration(Configuration0, Opts) -> {optional, authorization_response_iss_parameter_supported, false, fun oidcc_decode_util:parse_setting_boolean/2}, {optional, dpop_signing_alg_values_supported, undefined, - fun parse_token_signing_alg_values_no_none/2} + fun parse_token_signing_alg_values_no_none/2}, + {optional, require_signed_request_object, false, + fun oidcc_decode_util:parse_setting_boolean/2} ], #{} ), @@ -542,6 +546,7 @@ decode_configuration(Configuration0, Opts) -> authorization_response_iss_parameter_supported = AuthorizationResponseIssParameterSupported, dpop_signing_alg_values_supported = DpopSigningAlgValuesSupported, + require_signed_request_object = RequireSignedRequestObject, extra_fields = ExtraFields }} end. diff --git a/src/oidcc_token.erl b/src/oidcc_token.erl index 7fe4b16..28c1915 100644 --- a/src/oidcc_token.erl +++ b/src/oidcc_token.erl @@ -98,15 +98,17 @@ -type retrieve_opts() :: #{ pkce_verifier => binary(), + require_pkce => boolean(), nonce => binary() | any, scope => oidcc_scope:scopes(), preferred_auth_methods => [oidcc_auth_util:auth_method(), ...], refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(), - redirect_uri := uri_string:uri_string(), + redirect_uri => uri_string:uri_string(), request_opts => oidcc_http_util:request_opts(), url_extension => oidcc_http_util:query_params(), body_extension => oidcc_http_util:query_params(), - dpop_nonce => binary() + dpop_nonce => binary(), + trusted_audiences => [binary()] | any }. %% Options for retrieving a token %% @@ -118,6 +120,7 @@ %%
  • `pkce_verifier' - pkce verifier (random string previously given to %% `oidcc_authorization'), see %% [https://datatracker.ietf.org/doc/html/rfc7636#section-4.1]
  • +%%
  • `require_pkce' - whether to require PKCE when getting the token
  • %%
  • `nonce' - Nonce to check
  • %%
  • `scope' - Scope to store with the token
  • %%
  • `refresh_jwks' - How to handle tokens with an unknown `kid'. @@ -125,6 +128,8 @@ %%
  • `redirect_uri' - Redirect uri given to {@link oidcc_authorization:create_redirect_url/2}
  • %%
  • `dpop_nonce' - if using DPoP, the `nonce' value to use in the %% proof claim
  • +%%
  • `trusted_audiences' - if present, a list of additional audience values to +%% accept. Defaults to `any' which allows any additional values
  • %% -type refresh_opts_no_sub() :: @@ -178,6 +183,7 @@ -type error() :: {missing_claim, MissingClaim :: binary(), Claims :: oidcc_jwt_util:claims()} + | pkce_verifier_required | no_supported_auth_method | bad_access_token_hash | sub_invalid @@ -697,13 +703,11 @@ when TokenRecord :: id(), NoneUsed :: boolean(). extract_id_token(TokenMap, ClientContext, Opts) -> - Nonce = maps:get(nonce, Opts, any), - case maps:get(<<"id_token">>, TokenMap, none) of none -> {ok, {none, false}}; Token when is_binary(Token) -> - case validate_id_token(Token, ClientContext, Nonce) of + case validate_id_token(Token, ClientContext, Opts) of {ok, OkClaims} -> {ok, {#oidcc_token_id{token = Token, claims = OkClaims}, false}}; {error, {none_alg_used, NoneClaims}} -> @@ -755,14 +759,19 @@ verify_access_token_map_hash(#oidcc_token{}) -> %% ''' %% @end %% @since 3.0.0 --spec validate_id_token(IdToken, ClientContext, Nonce) -> +-spec validate_id_token(IdToken, ClientContext, NonceOrOpts) -> {ok, Claims} | {error, error()} when IdToken :: binary(), ClientContext :: oidcc_client_context:t(), + NonceOrOpts :: Nonce | retrieve_opts(), Nonce :: binary() | any, Claims :: oidcc_jwt_util:claims(). -validate_id_token(IdToken, ClientContext, Nonce) -> +validate_id_token(IdToken, ClientContext, Nonce) when is_binary(Nonce) -> + validate_id_token(IdToken, ClientContext, #{nonce => Nonce}); +validate_id_token(IdToken, ClientContext, any) -> + validate_id_token(IdToken, ClientContext, #{nonce => any}); +validate_id_token(IdToken, ClientContext, Opts) when is_map(Opts) -> #oidcc_client_context{ provider_configuration = Configuration, jwks = #jose_jwk{} = Jwks, @@ -775,6 +784,10 @@ validate_id_token(IdToken, ClientContext, Nonce) -> issuer = Issuer } = Configuration, + + Nonce = maps:get(nonce, Opts, any), + TrustedAudience = maps:get(trusted_audiences, Opts, any), + maybe ExpClaims0 = [{<<"iss">>, Issuer}], ExpClaims = @@ -808,7 +821,7 @@ validate_id_token(IdToken, ClientContext, Nonce) -> end, ok ?= oidcc_jwt_util:verify_claims(Claims, ExpClaims), ok ?= verify_missing_required_claims(Claims), - ok ?= verify_aud_claim(Claims, ClientId), + ok ?= verify_aud_claim(Claims, ClientId, TrustedAudience), ok ?= verify_azp_claim(Claims, ClientId), ok ?= verify_exp_claim(Claims), ok ?= verify_nbf_claim(Claims), @@ -871,16 +884,27 @@ authorization_headers( ), maps:from_list([{list_to_binary(Key), list_to_binary([Value])} || {Key, Value} <- Header]). --spec verify_aud_claim(Claims, ClientId) -> ok | {error, error()} when - Claims :: oidcc_jwt_util:claims(), ClientId :: binary(). -verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId) when is_list(Audience) -> +-spec verify_aud_claim(Claims, ClientId, TrustedAudience) -> ok | {error, error()} when + Claims :: oidcc_jwt_util:claims(), ClientId :: binary(), TrustedAudience :: [binary()] | any. +verify_aud_claim(#{<<"aud">> := ClientId}, ClientId, _TrustedAudience) -> + ok; +verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId, any) when is_list(Audience) -> case lists:member(ClientId, Audience) of true -> ok; false -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}} end; -verify_aud_claim(#{<<"aud">> := ClientId}, ClientId) -> - ok; -verify_aud_claim(Claims, ClientId) -> +verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId, TrustedAudience0) when + is_list(Audience) +-> + TrustedAudience = [ClientId | TrustedAudience0], + maybe + true ?= lists:member(ClientId, Audience), + [] ?= [A || A <- Audience, not lists:member(A, TrustedAudience)], + ok + else + _ -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}} + end; +verify_aud_claim(Claims, ClientId, _TrustedAudience) -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}}. -spec verify_azp_claim(Claims, ClientId) -> ok | {error, error()} when @@ -943,6 +967,10 @@ when Opts :: retrieve_opts() | refresh_opts(), TelemetryOpts :: oidcc_http_util:telemetry_opts(), AuthenticateClient :: boolean(). +retrieve_a_token( + _QsBodyIn, none, _ClientContext, #{require_pkce := true}, _TelemetryOpts, _AuthenticateClient +) -> + {error, pkce_verifier_required}; retrieve_a_token(QsBodyIn, PkceVerifier, ClientContext, Opts, TelemetryOpts, AuthenticateClient) -> #oidcc_client_context{provider_configuration = Configuration} = ClientContext, diff --git a/test/oidcc_authorization_test.erl b/test/oidcc_authorization_test.erl index 24dcfb6..5ef72c3 100644 --- a/test/oidcc_authorization_test.erl +++ b/test/oidcc_authorization_test.erl @@ -63,6 +63,8 @@ create_redirect_url_test() -> } ), Opts5 = maps:merge(BaseOpts, #{pkce_verifier => <<"foo">>}), + Opts6 = maps:merge(Opts5, #{require_pkce => true}), + Opts7 = maps:merge(BaseOpts, #{require_pkce => true}), {ok, Url1} = oidcc_authorization:create_redirect_url(ClientContext, BaseOpts), {ok, Url2} = oidcc_authorization:create_redirect_url(ClientContext, Opts1), @@ -72,6 +74,7 @@ create_redirect_url_test() -> {ok, Url6} = oidcc_authorization:create_redirect_url(ClientContext, Opts5), {ok, Url7} = oidcc_authorization:create_redirect_url(PkcePlainClientContext, Opts5), {ok, Url8} = oidcc_authorization:create_redirect_url(NoPkceClientContext, Opts5), + {ok, Url9} = oidcc_authorization:create_redirect_url(PkcePlainClientContext, Opts6), ExpUrl1 = <<"https://my.provider/auth?scope=openid&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn&test=id">>, @@ -105,6 +108,18 @@ create_redirect_url_test() -> <<"https://my.provider/auth?scope=openid&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn&test=id">>, ?assertEqual(ExpUrl8, iolist_to_binary(Url8)), + ?assertEqual(iolist_to_binary(Url9), iolist_to_binary(Url7)), + + ?assertEqual( + {error, no_supported_code_challenge}, + oidcc_authorization:create_redirect_url(NoPkceClientContext, Opts6) + ), + + ?assertEqual( + {error, pkce_verifier_required}, + oidcc_authorization:create_redirect_url(ClientContext, Opts7) + ), + ok. create_redirect_url_with_request_object_test() -> @@ -473,6 +488,39 @@ create_redirect_url_with_missing_config_request_object_test() -> ok. +create_redirect_url_with_missing_config_request_object_required_test() -> + PrivDir = code:priv_dir(oidcc), + + {ok, ValidConfigString} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"), + {ok, Configuration0} = oidcc_provider_configuration:decode_configuration( + jose:decode(ValidConfigString) + ), + + Configuration = Configuration0#oidcc_provider_configuration{ + request_parameter_supported = true, + require_signed_request_object = true + }, + + ClientId = <<"client_id">>, + ClientSecret = <<"at_least_32_character_client_secret">>, + + Jwks0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), + Jwks = Jwks0#jose_jwk{fields = #{<<"use">> => <<"sig">>}}, + + RedirectUri = <<"https://my.server/return">>, + + ClientContext = + oidcc_client_context:from_manual(Configuration, Jwks, ClientId, ClientSecret), + + ?assertEqual( + {error, request_object_required}, + oidcc_authorization:create_redirect_url(ClientContext, #{ + redirect_uri => RedirectUri + }) + ), + + ok. + create_redirect_url_with_request_object_only_none_alg_test() -> PrivDir = code:priv_dir(oidcc), diff --git a/test/oidcc_client_context_test.erl b/test/oidcc_client_context_test.erl index 3e036e8..fbb14e0 100644 --- a/test/oidcc_client_context_test.erl +++ b/test/oidcc_client_context_test.erl @@ -1,6 +1,11 @@ -module(oidcc_client_context_test). -include_lib("eunit/include/eunit.hrl"). +-include_lib("jose/include/jose_jwk.hrl"). +-include_lib("jose/include/jose_jws.hrl"). +-include_lib("jose/include/jose_jwt.hrl"). +-include_lib("oidcc/include/oidcc_client_context.hrl"). +-include_lib("oidcc/include/oidcc_provider_configuration.hrl"). provider_not_running_test() -> ?assertMatch( @@ -12,3 +17,126 @@ provider_not_running_test() -> ) ), ok. + +apply_profiles_fapi2_security_profile_test() -> + ClientContext0 = client_context_fixture(), + Opts0 = #{ + profiles => [fapi2_security_profile] + }, + + ProfileResult = oidcc_client_context:apply_profiles(ClientContext0, Opts0), + + ?assertMatch( + {ok, #oidcc_client_context{}, #{}}, + ProfileResult + ), + + {ok, ClientContext, Opts} = ProfileResult, + + ?assertMatch( + #oidcc_client_context{ + provider_configuration = #oidcc_provider_configuration{ + response_types_supported = [<<"code">>], + id_token_signing_alg_values_supported = [<<"EdDSA">>], + userinfo_signing_alg_values_supported = [ + <<"PS256">>, + <<"PS384">>, + <<"PS512">>, + <<"ES256">>, + <<"ES384">>, + <<"ES512">>, + <<"EdDSA">> + ], + code_challenge_methods_supported = [<<"S256">>], + require_pushed_authorization_requests = true, + authorization_response_iss_parameter_supported = true + } + }, + ClientContext + ), + + ?assertMatch( + #{ + preferred_auth_methods := [private_key_jwt], + require_pkce := true, + trusted_audiences := [] + }, + Opts + ), + + ok. + +apply_profiles_fapi2_message_signing_test() -> + ClientContext0 = client_context_fixture(), + Opts0 = #{ + profiles => [fapi2_message_signing] + }, + + ProfileResult = oidcc_client_context:apply_profiles(ClientContext0, Opts0), + + ?assertMatch( + {ok, #oidcc_client_context{}, #{}}, + ProfileResult + ), + + {ok, ClientContext, Opts} = ProfileResult, + + ?assertMatch( + #oidcc_client_context{ + provider_configuration = #oidcc_provider_configuration{ + response_types_supported = [<<"code">>], + id_token_signing_alg_values_supported = [<<"EdDSA">>], + userinfo_signing_alg_values_supported = [ + <<"PS256">>, + <<"PS384">>, + <<"PS512">>, + <<"ES256">>, + <<"ES384">>, + <<"ES512">>, + <<"EdDSA">> + ], + code_challenge_methods_supported = [<<"S256">>], + require_pushed_authorization_requests = true, + authorization_response_iss_parameter_supported = true + } + }, + ClientContext + ), + + ?assertMatch( + #{ + preferred_auth_methods := [private_key_jwt], + require_pkce := true, + trusted_audiences := [] + }, + Opts + ), + + ok. + +apply_profiles_unknown_test() -> + ClientContext = client_context_fixture(), + Opts = #{ + profiles => [unknown] + }, + + ?assertMatch( + {error, {unknown_profile, unknown}}, + oidcc_client_context:apply_profiles(ClientContext, Opts) + ), + + ok. + +client_context_fixture() -> + PrivDir = code:priv_dir(oidcc), + + {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/fapi2-metadata.json"), + {ok, #oidcc_provider_configuration{} = Configuration} = + oidcc_provider_configuration:decode_configuration(jose:decode(ConfigurationBinary)), + + Jwks = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk-ed25519.pem"), + + ClientId = <<"client_id">>, + ClientSecret = <<"client_secret">>, + + oidcc_client_context:from_manual(Configuration, Jwks, ClientId, ClientSecret). diff --git a/test/oidcc_token_test.erl b/test/oidcc_token_test.erl index 9135b48..d36945d 100644 --- a/test/oidcc_token_test.erl +++ b/test/oidcc_token_test.erl @@ -4,6 +4,7 @@ -include_lib("jose/include/jose_jwk.hrl"). -include_lib("jose/include/jose_jws.hrl"). -include_lib("jose/include/jose_jwt.hrl"). +-include_lib("oidcc/include/oidcc_client_context.hrl"). -include_lib("oidcc/include/oidcc_provider_configuration.hrl"). -include_lib("oidcc/include/oidcc_token.hrl"). @@ -1601,3 +1602,129 @@ authorization_headers_test() -> DpopJwtWithNonce ), ok. + +trusted_audiences_test() -> + ClientContext = + #oidcc_client_context{ + client_id = ClientId, + jwks = Jwk, + provider_configuration = #oidcc_provider_configuration{issuer = Issuer} + } = client_context_fixture(), + + ExtraAudience = <<"audience_member">>, + LocalEndpoint = <<"https://my.server/auth">>, + AuthCode = <<"1234567890">>, + AccessToken = <<"access_token">>, + Claims = + #{ + <<"iss">> => Issuer, + <<"sub">> => <<"sub">>, + <<"aud">> => [ClientId, ExtraAudience], + <<"azp">> => ClientId, + <<"iat">> => erlang:system_time(second), + <<"exp">> => erlang:system_time(second) + 10 + }, + + Jwt = jose_jwt:from(Claims), + Jws = #{<<"alg">> => <<"RS256">>}, + {_Jws, Token} = + jose_jws:compact( + jose_jwt:sign(Jwk, Jws, Jwt) + ), + + TokenData = + jsx:encode(#{ + <<"access_token">> => AccessToken, + <<"token_type">> => <<"Bearer">>, + <<"id_token">> => Token, + <<"scope">> => <<"profile openid">> + }), + + ok = meck:new(httpc, [no_link]), + HttpFun = + fun( + post, + {_TokenEndpoint, _Header, "application/x-www-form-urlencoded", _Body}, + _HttpOpts, + _Opts + ) -> + {ok, {{"HTTP/1.1", 200, "OK"}, [{"content-type", "application/json"}], TokenData}} + end, + ok = meck:expect(httpc, request, HttpFun), + + ?assertMatch( + {ok, #oidcc_token{}}, + oidcc_token:retrieve( + AuthCode, + ClientContext, + #{redirect_uri => LocalEndpoint} + ) + ), + + ?assertMatch( + {ok, #oidcc_token{}}, + oidcc_token:retrieve( + AuthCode, + ClientContext, + #{redirect_uri => LocalEndpoint, trusted_audiences => any} + ) + ), + + ?assertMatch( + {ok, #oidcc_token{}}, + oidcc_token:retrieve( + AuthCode, + ClientContext, + #{redirect_uri => LocalEndpoint, trusted_audiences => [ExtraAudience]} + ) + ), + + ?assertMatch( + {error, {missing_claim, {<<"aud">>, ClientId}, Claims}}, + oidcc_token:retrieve( + AuthCode, + ClientContext, + #{redirect_uri => LocalEndpoint, trusted_audiences => []} + ) + ), + + true = meck:validate(httpc), + + meck:unload(httpc), + + ok. + +retrieve_pkce_required_test() -> + ClientContext = client_context_fixture(), + RedirectUri = <<"https://redirect.example/">>, + + ?assertEqual( + {error, pkce_verifier_required}, + oidcc_token:retrieve(<<"code">>, ClientContext, #{ + redirect_uri => RedirectUri, + require_pkce => true + }) + ), + + ok. + +client_context_fixture() -> + PrivDir = code:priv_dir(oidcc), + + {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"), + {ok, Configuration} = oidcc_provider_configuration:decode_configuration( + jose:decode(ConfigurationBinary) + ), + + Jwk = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), + ClientJwk0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), + ClientJwk = ClientJwk0#jose_jwk{ + fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>} + }, + + ClientId = <<"client_id">>, + ClientSecret = <<"client_secret">>, + + oidcc_client_context:from_manual(Configuration, Jwk, ClientId, ClientSecret, #{ + client_jwks => ClientJwk + }).