diff --git a/src/oidcc_auth_util.erl b/src/oidcc_auth_util.erl index b4338a1..d7655f4 100644 --- a/src/oidcc_auth_util.erl +++ b/src/oidcc_auth_util.erl @@ -183,8 +183,9 @@ add_authentication( maybe [_ | _] ?= AllowAlgorithms, #jose_jwk{} ?= ClientJwks, + OptsWithAud = opts_with_aud(ClientContext, Opts), {ok, ClientAssertion} ?= - signed_client_assertion(AllowAlgorithms, Opts, ClientContext, ClientJwks), + signed_client_assertion(AllowAlgorithms, OptsWithAud, ClientContext, ClientJwks), {ok, add_jwt_bearer_assertion(ClientAssertion, QsBodyList, Header, ClientContext)} else _ -> @@ -393,3 +394,15 @@ dpop_proof(_Method, _Endpoint, _Claims, _ClientContext) -> -spec random_string(Bytes :: pos_integer()) -> binary(). random_string(Bytes) -> base64:encode(crypto:strong_rand_bytes(Bytes), #{mode => urlsafe, padding => false}). + +opts_with_aud(_ClientContext, #{audience := _} = Opts) -> + Opts; +opts_with_aud( + #oidcc_client_context{ + provider_configuration = #oidcc_provider_configuration{issuer = Issuer} + }, + #{jwt_aud_as_issuer := true} = Opts +) -> + maps:put(audience, Issuer, Opts); +opts_with_aud(_ClientContext, Opts) -> + Opts. diff --git a/src/oidcc_profile.erl b/src/oidcc_profile.erl index 0eb6462..d62cbbe 100644 --- a/src/oidcc_profile.erl +++ b/src/oidcc_profile.erl @@ -26,6 +26,7 @@ require_pkce => boolean(), trusted_audiences => [binary()] | any, preferred_auth_methods => [oidcc_auth_util:auth_method()], + jwt_aud_as_issuer => boolean(), request_opts => oidcc_http_util:request_opts() }. @@ -34,6 +35,7 @@ require_pkce => boolean(), trusted_audiences => [binary()] | any, preferred_auth_methods => [oidcc_auth_util:auth_method()], + jwt_aud_as_issuer => boolean(), request_opts => oidcc_http_util:request_opts() }. @@ -50,7 +52,7 @@ apply_profiles( #{profiles := [fapi2_security_profile | RestProfiles]} = Opts0 ) -> %% FAPI2 Security Profile - %% - https://openid.bitbucket.io/fapi/fapi-2_0-security-profile.html + %% - https://openid.bitbucket.io/fapi/fapi-security-profile-2_0.html {ClientContext1, Opts1} = enforce_s256_pkce(ClientContext0, Opts0), ClientContext2 = limit_response_types([<<"code">>], ClientContext1), ClientContext3 = enforce_par(ClientContext2), @@ -71,6 +73,10 @@ apply_profiles( Opts3 = map_put_new(trusted_audiences, [], Opts2), Opts4 = map_put_new(preferred_auth_methods, [private_key_jwt, tls_client_auth], Opts3), Opts5 = put_tls_defaults(Opts4), + %% 5.3.2.1 point 8 - shall only accept its issuer identifier value (as + %% defined in [RFC8414]) as a string in the aud claim received in client + %% authentication assertions; + Opts6 = map_put_new(jwt_aud_as_issuer, true, Opts5), Opts = limit_tls_ciphers( [ "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", @@ -78,7 +84,7 @@ apply_profiles( "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" ], - Opts5 + Opts6 ), apply_profiles(ClientContext, Opts); apply_profiles( diff --git a/src/oidcc_token.erl b/src/oidcc_token.erl index 4e22637..125bb59 100644 --- a/src/oidcc_token.erl +++ b/src/oidcc_token.erl @@ -124,6 +124,8 @@ See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3. * `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. +* `jwt_aud_as_issuer` - whether to use the issuer as the audience for JWTs. + Defaults to false. """). ?DOC(#{since => <<"3.0.0">>}). -type retrieve_opts() :: @@ -139,7 +141,8 @@ See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3. url_extension => oidcc_http_util:query_params(), body_extension => oidcc_http_util:query_params(), dpop_nonce => binary(), - trusted_audiences => [binary()] | any + trusted_audiences => [binary()] | any, + jwt_aud_as_issuer => boolean() }. ?DOC("See `t:refresh_opts_no_sub/0`."). diff --git a/test/oidcc_client_context_test.erl b/test/oidcc_client_context_test.erl index 6d4527a..a7072b0 100644 --- a/test/oidcc_client_context_test.erl +++ b/test/oidcc_client_context_test.erl @@ -60,6 +60,7 @@ apply_profiles_fapi2_security_profile_test() -> preferred_auth_methods := [private_key_jwt, tls_client_auth], require_pkce := true, trusted_audiences := [], + jwt_aud_as_issuer := true, request_opts := #{ ssl := _ } @@ -154,6 +155,7 @@ apply_profiles_fapi2_message_signing_test() -> preferred_auth_methods := [private_key_jwt, tls_client_auth], require_pkce := true, trusted_audiences := [], + jwt_aud_as_issuer := true, request_opts := #{ ssl := _ } diff --git a/test/oidcc_token_test.erl b/test/oidcc_token_test.erl index 2eef3ae..0e80b65 100644 --- a/test/oidcc_token_test.erl +++ b/test/oidcc_token_test.erl @@ -900,6 +900,149 @@ auth_method_private_key_jwt_test() -> meck:unload(httpc), + ok. +auth_method_private_key_jwt_aud_as_issuer_test() -> + PrivDir = code:priv_dir(oidcc), + + {ok, _} = application:ensure_all_started(oidcc), + + {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"), + {ok, Configuration0} = oidcc_provider_configuration:decode_configuration( + jose:decode(ConfigurationBinary) + ), + + #oidcc_provider_configuration{token_endpoint = TokenEndpoint, issuer = Issuer} = + Configuration = Configuration0#oidcc_provider_configuration{ + token_endpoint_auth_methods_supported = [<<"private_key_jwt">>], + token_endpoint_auth_signing_alg_values_supported = [<<"RS256">>] + }, + + ClientId = <<"client_id">>, + ClientSecret = <<"client_secret">>, + LocalEndpoint = <<"https://my.server/auth">>, + AuthCode = <<"1234567890">>, + AccessToken = <<"access_token">>, + RefreshToken = <<"refresh_token">>, + Claims = + #{ + <<"iss">> => Issuer, + <<"sub">> => <<"sub">>, + <<"aud">> => ClientId, + <<"iat">> => erlang:system_time(second), + <<"exp">> => erlang:system_time(second) + 10, + <<"at_hash">> => <<"hrOQHuo3oE6FR82RIiX1SA">> + }, + + Jwk = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), + + 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">>, + <<"refresh_token">> => RefreshToken + }), + + ClientJwk0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), + ClientJwk = ClientJwk0#jose_jwk{ + fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>} + }, + + ClientContext = oidcc_client_context:from_manual(Configuration, Jwk, ClientId, ClientSecret, #{ + client_jwks => ClientJwk + }), + + ok = meck:new(httpc, [no_link]), + HttpFun = + fun( + post, + {ReqTokenEndpoint, Header, "application/x-www-form-urlencoded", Body}, + _HttpOpts, + _Opts, + _Profile + ) -> + TokenEndpoint = ReqTokenEndpoint, + ?assertMatch(none, proplists:lookup("authorization", Header)), + BodyMap = maps:from_list(uri_string:dissect_query(Body)), + + ?assertMatch( + #{ + <<"grant_type">> := <<"authorization_code">>, + <<"code">> := AuthCode, + <<"client_id">> := ClientId, + <<"client_assertion_type">> := + <<"urn:ietf:params:oauth:client-assertion-type:jwt-bearer">>, + <<"client_assertion">> := _ + }, + BodyMap + ), + + ClientAssertion = maps:get(<<"client_assertion">>, BodyMap), + + {true, ClientAssertionJwt, ClientAssertionJws} = jose_jwt:verify( + ClientJwk, ClientAssertion + ), + + ?assertMatch( + #jose_jws{alg = {_, 'RS256'}}, ClientAssertionJws + ), + + #jose_jws{fields = ClientAssertionJwsFields} = ClientAssertionJws, + ?assertMatch( + #{ + <<"kid">> := <<"private_kid">> + }, + ClientAssertionJwsFields + ), + + ?assertMatch( + #jose_jwt{ + fields = #{ + <<"aud">> := Issuer, + <<"exp">> := _, + <<"iat">> := _, + <<"iss">> := ClientId, + <<"jti">> := _, + <<"nbf">> := _, + <<"sub">> := ClientId + } + }, + ClientAssertionJwt + ), + + {ok, {{"HTTP/1.1", 200, "OK"}, [{"content-type", "application/json"}], TokenData}} + end, + ok = meck:expect(httpc, request, HttpFun), + + ?assertMatch( + {ok, #oidcc_token{ + id = #oidcc_token_id{token = Token, claims = Claims}, + access = #oidcc_token_access{token = AccessToken}, + refresh = #oidcc_token_refresh{token = RefreshToken}, + scope = [<<"profile">>, <<"openid">>] + }}, + oidcc_token:retrieve( + AuthCode, + ClientContext, + #{ + redirect_uri => LocalEndpoint, + jwt_aud_as_issuer => true + } + ) + ), + + true = meck:validate(httpc), + + meck:unload(httpc), + ok. auth_method_private_key_jwt_with_dpop_test() ->