Skip to content

Commit

Permalink
feat: set aud claim to Issuer for FAPI2 profiles
Browse files Browse the repository at this point in the history
At some point, this became part of the spec:

https://openid.bitbucket.io/fapi/fapi-security-profile-2_0.html#section-5.3.1

> Authorization servers [...] shall only accept its issuer identifier value (as defined in [RFC8414]) as a string in the aud claim received in client authentication assertions;

We add a parameter to control this, and set it as a part of the FAPI2 profiles.
  • Loading branch information
paulswartz committed Feb 8, 2025
1 parent e2249b7 commit a5d39b2
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 4 deletions.
15 changes: 14 additions & 1 deletion src/oidcc_auth_util.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
_ ->
Expand Down Expand Up @@ -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.
10 changes: 8 additions & 2 deletions src/oidcc_profile.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}.

Expand All @@ -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()
}.

Expand All @@ -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),
Expand All @@ -71,14 +73,18 @@ 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",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_DHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
],
Opts5
Opts6
),
apply_profiles(ClientContext, Opts);
apply_profiles(
Expand Down
5 changes: 4 additions & 1 deletion src/oidcc_token.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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() ::
Expand All @@ -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`.").
Expand Down
2 changes: 2 additions & 0 deletions test/oidcc_client_context_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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 := _
}
Expand Down Expand Up @@ -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 := _
}
Expand Down
143 changes: 143 additions & 0 deletions test/oidcc_token_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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() ->
Expand Down

0 comments on commit a5d39b2

Please sign in to comment.