diff --git a/README.md b/README.md index 3b6a989..c5d998e 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ The refactoring for `v3` and the certification is funded as an * [Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662) * Logout * [RP-Initiated](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) +* [JWT Secured Authorization Response Mode for OAuth 2.0 (JARM)](https://openid.net/specs/oauth-v2-jarm-final.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) diff --git a/lib/oidcc/token.ex b/lib/oidcc/token.ex index a9dcbf3..4e289fd 100644 --- a/lib/oidcc/token.ex +++ b/lib/oidcc/token.ex @@ -162,6 +162,50 @@ defmodule Oidcc.Token do |> normalize_token_response() end + @doc """ + Validate the JARM response, returning the valid claims as a map. + + the response was sent to the local endpoint by the OpenId Connect provider, + using redirects + + ## Examples + + iex> {:ok, pid} = + ...> Oidcc.ProviderConfiguration.Worker.start_link(%{ + ...> issuer: "https://api.login.yahoo.com" + ...> }) + ...> + ...> {:ok, client_context} = + ...> Oidcc.ClientContext.from_configuration_worker( + ...> pid, + ...> "client_id", + ...> "client_secret" + ...> ) + ...> + ...> # Get auth_code fromm redirect + ...> response = "JWT" + ...> + ...> Oidcc.Token.validate_jarm( + ...> response, + ...> client_context, + ...> %{} + ...> ) + ...> # => {:ok, %{"code" => auth_code}} + + """ + @doc since: "3.2.0" + @spec validate_jarm( + response :: String.t(), + client_context :: ClientContext.t(), + opts :: :oidcc_token.validate_jarm_opts() + ) :: + {:ok, :oidcc_jwt_util.claims()} | {:error, :oidcc_token.error()} + def validate_jarm(response, client_context, opts) do + client_context = ClientContext.struct_to_record(client_context) + + :oidcc_token.validate_jarm(response, client_context, opts) + end + @doc """ Refresh Token diff --git a/priv/test/fixtures/fapi2-metadata.json b/priv/test/fixtures/fapi2-metadata.json index 269abd9..8570296 100644 --- a/priv/test/fixtures/fapi2-metadata.json +++ b/priv/test/fixtures/fapi2-metadata.json @@ -18,6 +18,12 @@ "code token id_token", "none" ], + "response_modes_supported": [ + "query", + "fragment", + "jwt", + "query.jwt" + ], "subject_types_supported": [ "public" ], @@ -102,5 +108,45 @@ "A128GCM", "A192GCM", "A256GCM" + ], + "authorization_signing_alg_values_supported": [ + "HS256", + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES256K", + "ES384", + "ES512", + "EdDSA" + ], + "authorization_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" + ], + "authorization_encryption_enc_values_supported": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" ] } diff --git a/src/oidcc_authorization.erl b/src/oidcc_authorization.erl index 8dd8063..7393130 100644 --- a/src/oidcc_authorization.erl +++ b/src/oidcc_authorization.erl @@ -25,7 +25,8 @@ pkce_verifier => binary(), require_pkce => boolean(), redirect_uri => uri_string:uri_string(), - url_extension => oidcc_http_util:query_params() + url_extension => oidcc_http_util:query_params(), + response_mode => binary() }. %% Configure authorization redirect url %% @@ -42,6 +43,7 @@ %%
  • `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
  • +%%
  • `response_mode' - response mode to use (defaults to `<<"query">>')
  • %% -type error() :: @@ -111,17 +113,24 @@ 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 = + case maps:get(response_mode, Opts, <<"query">>) of + <<"query">> -> + QueryParams2; + ResponseMode when is_binary(ResponseMode) -> + [{<<"response_mode">>, ResponseMode} | QueryParams2] + end, maybe - {ok, QueryParams3} ?= + {ok, QueryParams4} ?= append_code_challenge( - Opts, QueryParams2, ClientContext + Opts, QueryParams3, ClientContext ), - QueryParams4 = oidcc_scope:query_append_scope( - maps:get(scopes, Opts, [openid]), QueryParams3 + QueryParams5 = oidcc_scope:query_append_scope( + maps:get(scopes, Opts, [openid]), QueryParams4 ), - QueryParams5 = maybe_append_dpop_jkt(QueryParams4, ClientContext), - {ok, QueryParams6} ?= attempt_request_object(QueryParams5, ClientContext), - attempt_par(QueryParams6, ClientContext, Opts) + QueryParams6 = maybe_append_dpop_jkt(QueryParams5, ClientContext), + {ok, QueryParams7} ?= attempt_request_object(QueryParams6, ClientContext), + attempt_par(QueryParams7, ClientContext, Opts) end. -spec append_code_challenge(Opts, QueryParams, ClientContext) -> diff --git a/src/oidcc_jwt_util.erl b/src/oidcc_jwt_util.erl index 5c53bcd..595ef58 100644 --- a/src/oidcc_jwt_util.erl +++ b/src/oidcc_jwt_util.erl @@ -6,18 +6,22 @@ -feature(maybe_expr, enable). +-include_lib("jose/include/jose_jwe.hrl"). -include_lib("jose/include/jose_jwk.hrl"). -include_lib("jose/include/jose_jws.hrl"). -include_lib("jose/include/jose_jwt.hrl"). -export([client_secret_oct_keys/2]). +-export([decrypt_if_needed/4]). -export([encrypt/4]). -export([evaluate_for_all_keys/2]). -export([merge_jwks/2]). +-export([peek_payload/1]). -export([refresh_jwks_fun/1]). -export([sign/3]). -export([sign/4]). -export([verify_claims/2]). +-export([verify_not_none_alg/1]). -export([verify_signature/3]). -export_type([claims/0]). @@ -31,7 +35,9 @@ no_matching_key | invalid_jwt_token | {no_matching_key_with_kid, Kid :: binary()} - | {none_alg_used, Jwt :: #jose_jwt{}, Jws :: #jose_jws{}}. + | none_alg_used + | {none_alg_used, Jwt :: #jose_jwt{}, Jws :: #jose_jws{}} + | not_encrypted. -type claims() :: #{binary() => term()}. @@ -188,14 +194,22 @@ sign(Jwt, Jwk, [Algorithm | RestAlgorithms], JwsFields0) -> #jose_jws{fields = JwsFields} = Jws0 ?= jose_jws:from_map(JwsFields0#{<<"alg">> => Algorithm}), SigningCallback = fun - (#jose_jwk{fields = #{<<"use">> := <<"sig">>} = Fields} = Key) -> + (#jose_jwk{fields = Fields} = Key) when Algorithm =/= <<"none">> -> %% add the kid field to the JWS signature if present KidField = maps:with([<<"kid">>], Fields), Jws = Jws0#jose_jws{fields = maps:merge(KidField, JwsFields)}, try + %% ensure key is either for signatures, or not specified + ok = + case Fields of + #{<<"use">> := <<"sig">>} -> ok; + #{<<"use">> := _} -> error; + #{} -> ok + end, {_Jws, Token} = jose_jws:compact(jose_jwt:sign(Key, Jws, Jwt)), {ok, Token} catch + error:{badmatch, _} -> error; error:not_supported -> error; error:{not_supported, _Alg} -> error; %% Some Keys crash if a public key is provided @@ -218,6 +232,103 @@ sign(Jwt, Jwk, [Algorithm | RestAlgorithms], JwsFields0) -> _ -> sign(Jwt, Jwk, RestAlgorithms, JwsFields0) end. +%% @private +-spec decrypt_if_needed( + Jwt :: binary(), + Jwk :: jose_jwk:key(), + SupportedAlgorithms :: [binary()] | undefined, + SupportedEncValues :: [binary()] | undefined +) -> + {ok, binary()} | {error, no_supported_alg_or_key}. +decrypt_if_needed(Jwt, Jwk, SupportedAlgorithms, SupportedEncValues) -> + case decrypt(Jwt, Jwk, SupportedAlgorithms, SupportedEncValues) of + {ok, Decrypted} -> {ok, Decrypted}; + {error, not_encrypted} -> {ok, Jwt}; + {error, Reason} -> {error, Reason} + end. + +-spec jwe_peek_protected(Jwt :: binary()) -> + {ok, #jose_jwe{}} | {error, not_encrypted | no_matching_key}. +jwe_peek_protected(Jwt) -> + %% jose_jwt:peek_protected(Jwt) doesn't work with encrypted tokens + maybe + [ProtectedEncoded, _, _, _, _] ?= binary:split(Jwt, <<".">>, [global]), + Protected = jose_jwa_base64url:decode(ProtectedEncoded), + #jose_jwe{} = Jwe ?= jose_jwe:from(Protected), + {ok, Jwe} + else + [_, _, _] -> + {error, not_encrypted}; + _ -> + {error, no_matching_key} + end. + +-spec decrypt( + Jwt :: binary(), + Jwk :: jose_jwk:key(), + SupportedAlgorithms :: [binary()] | undefined, + SupportedEncValues :: [binary()] | undefined +) -> + {ok, binary()} | {error, error()}. +decrypt(_Jwt, _Jwk, undefined, _SupportedEncValues) -> + {error, no_supported_alg_or_key}; +decrypt(_Jwt, _Jwk, _SupportedAlgorithms, undefined) -> + {error, no_supported_alg_or_key}; +decrypt(Jwt, #jose_jwk{keys = {jose_jwk_set, Keys}}, SupportedAlgorithms, SupportedEncValues) -> + lists:foldl( + fun + (_Key, {ok, _Res} = Acc) -> + Acc; + (Key, Acc) -> + case {decrypt(Jwt, Key, SupportedAlgorithms, SupportedEncValues), Acc} of + {{ok, Res}, _Acc} -> + {ok, Res}; + {_Res, {error, {no_matching_key_with_kid, Kid}}} -> + {error, {no_matching_key_with_kid, Kid}}; + {Res, _Acc} -> + Res + end + end, + {error, no_matching_key}, + Keys + ); +decrypt(Jwt, #jose_jwk{} = Jwk, SupportedAlgorithms, SupportedEncValues) -> + maybe + {ok, Jwe} ?= jwe_peek_protected(Jwt), + {_, #{<<"alg">> := JwtAlg, <<"enc">> := JwtEnc}} = jose_jwe:to_map(Jwe), + ok ?= verify_in_list(JwtAlg, SupportedAlgorithms), + ok ?= verify_in_list(JwtEnc, SupportedEncValues), + Kid = + case Jwe of + #jose_jwe{fields = #{<<"kid">> := IntKid}} -> + IntKid; + #jose_jwe{} -> + none + end, + case Jwk of + #jose_jwk{fields = #{<<"kid">> := CmpKid}} when CmpKid =/= Kid, Kid =/= none -> + {error, {no_matching_key_with_kid, Kid}}; + _ -> + try + {Token, _Jwe} = jose_jwe:block_decrypt(Jwk, Jwt), + {ok, Token} + catch + error:_ when Kid =:= none -> + {error, no_matching_key}; + error:_ -> + {error, {no_matching_key_with_kid, Kid}} + end + end + end. + +verify_in_list(Value, List) -> + case lists:member(Value, List) of + true -> + ok; + false -> + {error, no_matching_key} + end. + %% @private -spec encrypt( Jwt :: binary(), @@ -289,3 +400,22 @@ evaluate_for_all_keys(#jose_jwk{keys = {jose_jwk_set, Keys}}, Callback) -> ); evaluate_for_all_keys(#jose_jwk{} = Jwk, Callback) -> Callback(Jwk). + +%% @private +-spec verify_not_none_alg(#jose_jws{}) -> ok | {error, none_alg_used}. +verify_not_none_alg(#jose_jws{fields = #{<<"alg">> := <<"none">>}}) -> + {error, none_alg_used}; +verify_not_none_alg(#jose_jws{}) -> + ok. + +%% @private +-spec peek_payload(binary()) -> {ok, #jose_jwt{}} | {error, invalid_jwt_token}. +peek_payload(Jwt) -> + try + {ok, jose_jwt:peek_payload(Jwt)} + catch + error:{badarg, [_Token]} -> + {error, invalid_jwt_token}; + error:function_clause -> + {error, invalid_jwt_token} + end. diff --git a/src/oidcc_profile.erl b/src/oidcc_profile.erl index fc5a9bf..70a4bc9 100644 --- a/src/oidcc_profile.erl +++ b/src/oidcc_profile.erl @@ -64,16 +64,18 @@ apply_profiles( Opts = map_put_new(preferred_auth_methods, [private_key_jwt], Opts3), apply_profiles(ClientContext, Opts); apply_profiles( - #oidcc_client_context{} = ClientContext, + #oidcc_client_context{} = ClientContext0, #{profiles := [fapi2_message_signing | RestProfiles]} = Opts0 ) -> %% FAPI2 Message Signing: - %% - https://openid.bitbucket.io/fapi/fapi-2_0-message- signing.html + %% - https://openid.bitbucket.io/fapi/fapi-2_0-message-signing.html + + ClientContext = limit_response_modes( + [<<"jwt">>, <<"query.jwt">>, <<"form_post.jwt">>], ClientContext0 + ), %% 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 @@ -116,6 +118,19 @@ limit_response_types(Types, ClientContext0) -> }, ClientContext. +limit_response_modes(Modes, ClientContext0) -> + #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0, + #oidcc_provider_configuration{ + response_modes_supported = ResponseModes + } = ProviderConfiguration0, + ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{ + response_modes_supported = limit_values(Modes, ResponseModes) + }, + ClientContext = ClientContext0#oidcc_client_context{ + provider_configuration = ProviderConfiguration + }, + ClientContext. + enforce_par(ClientContext0) -> #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0, ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{ @@ -170,12 +185,7 @@ limit_signing_alg_values(AlgSupported, ClientContext0) -> limit_values(_Limit, undefined) -> undefined; limit_values(Limit, Values) -> - case [V || V <- Values, lists:member(V, Limit)] of - [] -> - undefined; - Filtered -> - Filtered - end. + [V || V <- Values, lists:member(V, Limit)]. map_put_new(Key, Value, Map) -> case Map of diff --git a/src/oidcc_token.erl b/src/oidcc_token.erl index 28c1915..4c17123 100644 --- a/src/oidcc_token.erl +++ b/src/oidcc_token.erl @@ -31,6 +31,7 @@ -export([jwt_profile/4]). -export([refresh/3]). -export([retrieve/3]). +-export([validate_jarm/3]). -export([validate_id_token/3]). -export([authorization_headers/4]). -export([authorization_headers/5]). @@ -44,6 +45,7 @@ -export_type([refresh_opts/0]). -export_type([refresh_opts_no_sub/0]). -export_type([retrieve_opts/0]). +-export_type([validate_jarm_opts/0]). -export_type([t/0]). -type id() :: #oidcc_token_id{token :: binary(), claims :: oidcc_jwt_util:claims()}. @@ -151,6 +153,11 @@ url_extension => oidcc_http_util:query_params(), body_extension => oidcc_http_util:query_params() }. + +-type validate_jarm_opts() :: + #{ + trusted_audiences => [binary()] | any + }. %% Options for refreshing a token %% %% See [https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3] @@ -349,6 +356,120 @@ retrieve(AuthCode, ClientContext, Opts) -> {error, {grant_type_not_supported, authorization_code}} end. +%% @doc +%% Validate the JARM response, returning the valid claims as a map. +%% +%% The response was sent to the local endpoint by the OpenId Connect provider, +%% using redirects +%% +%%

    Examples

    +%% +%% ``` +%% {ok, ClientContext} = +%% oidcc_client_context:from_configuration_worker(provider_name, +%% <<"client_id">>, +%% <<"client_secret">>), +%% +%% %% Get Response from Redirect +%% +%% {ok, #{<<"code">> := AuthCode}} = +%% oidcc:validate_jarm(Response, ClientContext, #{}), +%% +%% {ok, #oidcc_token{}} = oidcc:retrieve(AuthCode, ClientContext, +%% #{redirect_uri => <<"https://redirect.example/">>}. +%% ''' +%% @end +%% @since 3.2.0 +-spec validate_jarm(Response, ClientContext, Opts) -> + {ok, oidcc_jwt_util:claims()} | {error, error()} +when + Response :: binary(), + ClientContext :: oidcc_client_context:t(), + Opts :: validate_jarm_opts(). +validate_jarm(Response, ClientContext, Opts) -> + #oidcc_client_context{ + provider_configuration = Configuration, + client_id = ClientId, + client_secret = ClientSecret, + client_jwks = ClientJwks, + jwks = Jwks + } = ClientContext, + #oidcc_provider_configuration{ + issuer = Issuer, + authorization_signing_alg_values_supported = SigningAlgSupported0, + authorization_encryption_alg_values_supported = EncryptionAlgSupported0, + authorization_encryption_enc_values_supported = EncryptionEncSupported0 + } = + Configuration, + + SigningAlgSupported = + case SigningAlgSupported0 of + undefined -> []; + SigningAlgs -> SigningAlgs + end, + EncryptionAlgSupported = + case EncryptionAlgSupported0 of + undefined -> []; + EncryptionAlgs -> EncryptionAlgs + end, + EncryptionEncSupported = + case EncryptionEncSupported0 of + undefined -> []; + EncryptionEncs -> EncryptionEncs + end, + JwksWithClientJwks = + case ClientJwks of + none -> Jwks; + #jose_jwk{} -> oidcc_jwt_util:merge_jwks(Jwks, ClientJwks) + end, + + SigningJwks = + case oidcc_jwt_util:client_secret_oct_keys(SigningAlgSupported, ClientSecret) of + none -> + JwksWithClientJwks; + SigningOctJwk -> + oidcc_jwt_util:merge_jwks(JwksWithClientJwks, SigningOctJwk) + end, + EncryptionJwks = + case oidcc_jwt_util:client_secret_oct_keys(EncryptionAlgSupported, ClientSecret) of + none -> + JwksWithClientJwks; + EncryptionOctJwk -> + oidcc_jwt_util:merge_jwks(JwksWithClientJwks, EncryptionOctJwk) + end, + %% https://openid.net/specs/oauth-v2-jarm-final.html#name-processing-rules + %% 1. decrypt if necessary + %% 2. validate <<"iss">> claim + %% 3. validate <<"aud">> claim + %% 4. validate <<"exp">> claim + %% 5. validate signature (valid, not <<"none">> alg) + %% 6. continue processing + maybe + {ok, DecryptedResponse} ?= + oidcc_jwt_util:decrypt_if_needed( + Response, + EncryptionJwks, + EncryptionAlgSupported, + EncryptionEncSupported + ), + ExpClaims = [ + {<<"iss">>, Issuer} + ], + TrustedAudience = maps:get(trusted_audiences, Opts, any), + {ok, #jose_jwt{fields = PeekClaims}} ?= + oidcc_jwt_util:peek_payload(DecryptedResponse), + ok ?= oidcc_jwt_util:verify_claims(PeekClaims, ExpClaims), + ok ?= verify_aud_claim(PeekClaims, ClientId, TrustedAudience), + ok ?= verify_exp_claim(PeekClaims), + ok ?= verify_nbf_claim(PeekClaims), + {ok, {#jose_jwt{fields = Claims}, Jws}} ?= + oidcc_jwt_util:verify_signature( + DecryptedResponse, SigningAlgSupported, SigningJwks + ), + ok ?= oidcc_jwt_util:verify_not_none_alg(Jws), + {ok, Claims} + end. + %% @doc Refresh Token %% %% For a high level interface using {@link oidcc_provider_configuration_worker} diff --git a/test/oidcc_authorization_test.erl b/test/oidcc_authorization_test.erl index 5ef72c3..c7b6e68 100644 --- a/test/oidcc_authorization_test.erl +++ b/test/oidcc_authorization_test.erl @@ -1056,6 +1056,55 @@ create_redirect_url_with_par_client_secret_jwt_request_object_test() -> ok. create_redirect_url_private_key_jwt_test() -> + ClientContext = private_key_jwt_fixture(), + RedirectUri = <<"https://my.server/return">>, + + Opts = + #{ + redirect_uri => RedirectUri + }, + + {ok, Url} = oidcc_authorization:create_redirect_url(ClientContext, Opts), + + ExpUrl = + <<"https://my.provider/auth?dpop_jkt=7jnO2y748F6HEP7WtfubjBQWOgKUuMBQoYLyyc1fe-Q&scope=openid&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn">>, + ?assertEqual(ExpUrl, iolist_to_binary(Url)), + + ok. + +create_redirect_url_response_mode_jwt_test() -> + ClientContext = private_key_jwt_fixture(), + RedirectUri = <<"https://my.server/return">>, + + Opts = + #{ + redirect_uri => RedirectUri + }, + + {ok, Url1} = oidcc_authorization:create_redirect_url(ClientContext, Opts#{ + response_mode => <<"jwt">> + }), + {ok, Url2} = oidcc_authorization:create_redirect_url(ClientContext, Opts#{ + response_mode => <<"query.jwt">> + }), + + ?assertMatch( + #{ + "response_mode" := "jwt" + }, + parse_query_string(Url1) + ), + + ?assertMatch( + #{ + "response_mode" := "query.jwt" + }, + parse_query_string(Url2) + ), + + ok. + +private_key_jwt_fixture() -> PrivDir = code:priv_dir(oidcc), {ok, ValidConfigString} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"), @@ -1070,22 +1119,15 @@ create_redirect_url_private_key_jwt_test() -> Jwks = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), ClientId = <<"client_id">>, - RedirectUri = <<"https://my.server/return">>, ClientContext = oidcc_client_context:from_manual(Configuration, Jwks, ClientId, <<"client_secret">>, #{ client_jwks => Jwks }), - Opts = - #{ - redirect_uri => RedirectUri - }, - - {ok, Url} = oidcc_authorization:create_redirect_url(ClientContext, Opts), - - ExpUrl = - <<"https://my.provider/auth?dpop_jkt=7jnO2y748F6HEP7WtfubjBQWOgKUuMBQoYLyyc1fe-Q&scope=openid&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn">>, - ?assertEqual(ExpUrl, iolist_to_binary(Url)), + ClientContext. - ok. +parse_query_string(UriString) -> + #{query := QueryStringBinary} = uri_string:parse(UriString), + QueryList = uri_string:dissect_query(QueryStringBinary), + maps:from_list(QueryList). diff --git a/test/oidcc_client_context_test.erl b/test/oidcc_client_context_test.erl index fbb14e0..ac277bd 100644 --- a/test/oidcc_client_context_test.erl +++ b/test/oidcc_client_context_test.erl @@ -85,6 +85,7 @@ apply_profiles_fapi2_message_signing_test() -> #oidcc_client_context{ provider_configuration = #oidcc_provider_configuration{ response_types_supported = [<<"code">>], + response_modes_supported = [<<"jwt">>, <<"query.jwt">>], id_token_signing_alg_values_supported = [<<"EdDSA">>], userinfo_signing_alg_values_supported = [ <<"PS256">>, diff --git a/test/oidcc_token_test.erl b/test/oidcc_token_test.erl index d36945d..fcbb396 100644 --- a/test/oidcc_token_test.erl +++ b/test/oidcc_token_test.erl @@ -1609,7 +1609,7 @@ trusted_audiences_test() -> client_id = ClientId, jwks = Jwk, provider_configuration = #oidcc_provider_configuration{issuer = Issuer} - } = client_context_fixture(), + } = client_context_fapi2_fixture(), ExtraAudience = <<"audience_member">>, LocalEndpoint = <<"https://my.server/auth">>, @@ -1695,7 +1695,7 @@ trusted_audiences_test() -> ok. retrieve_pkce_required_test() -> - ClientContext = client_context_fixture(), + ClientContext = client_context_fapi2_fixture(), RedirectUri = <<"https://redirect.example/">>, ?assertEqual( @@ -1708,15 +1708,198 @@ retrieve_pkce_required_test() -> ok. -client_context_fixture() -> +validate_jarm_test() -> + ClientContext0 = client_context_fapi2_fixture(), + #oidcc_client_context{ + client_id = ClientId, + jwks = Jwk, + provider_configuration = + #oidcc_provider_configuration{ + issuer = Issuer + } + } = ClientContext0, + EncAlgValue = <<"RSA-OAEP-256">>, + EncEncValue = <<"A256GCM">>, + EncJwk0 = jose_jwk:generate_key({rsa, 2048}), + EncJwk = EncJwk0#jose_jwk{fields = #{<<"use">> => <<"enc">>}}, + ClientContext = ClientContext0#oidcc_client_context{ + jwks = oidcc_jwt_util:merge_jwks(Jwk, EncJwk) + }, + Jws = #{<<"alg">> => <<"RS256">>}, + AuthCode = <<"123456">>, + JarmClaims = #{ + <<"iss">> => Issuer, + <<"aud">> => ClientId, + <<"code">> => AuthCode, + <<"exp">> => erlang:system_time(second) + 10 + }, + {_, JarmToken0} = jose_jws:compact( + jose_jwt:sign(Jwk, Jws, JarmClaims) + ), + {_, JarmToken} = jose_jwe:compact( + jose_jwk:block_encrypt( + JarmToken0, + jose_jwe:from_map(#{<<"alg">> => EncAlgValue, <<"enc">> => EncEncValue}), + EncJwk + ) + ), + + ?assertEqual( + {ok, JarmClaims}, + oidcc_token:validate_jarm( + JarmToken, + ClientContext, + #{} + ) + ), + + ok. + +validate_jarm_invalid_token_test() -> + ClientContext = client_context_fapi2_fixture(), + #oidcc_client_context{ + client_id = ClientId, + jwks = Jwk, + provider_configuration = + #oidcc_provider_configuration{ + issuer = Issuer + } + } = ClientContext, + + Jws = #{<<"alg">> => <<"RS256">>}, + RedirectUri = <<"https://redirect.example/">>, + JarmClaims = #{ + <<"iss">> => Issuer, + <<"aud">> => ClientId, + <<"code">> => <<"123456">>, + <<"exp">> => erlang:system_time(second) + 10 + }, + JarmClaimsInvalidIssuer = JarmClaims#{ + <<"iss">> => <<"invalid">> + }, + JarmClaimsExtraAudience = JarmClaims#{ + <<"aud">> => [ClientId, <<"extra">>] + }, + JarmClaimsExpired = JarmClaims#{ + <<"exp">> => erlang:system_time(second) - 10 + }, + JarmClaimsNotYetValid = JarmClaims#{ + <<"nbf">> => erlang:system_time(second) + 10 + }, + {_, JarmTokenInvalidIssuer} = jose_jws:compact( + jose_jwt:sign(Jwk, Jws, jose_jwt:from(JarmClaimsInvalidIssuer)) + ), + {_, JarmTokenExtraAudience} = jose_jws:compact( + jose_jwt:sign(Jwk, Jws, jose_jwt:from(JarmClaimsExtraAudience)) + ), + {_, JarmTokenExpired} = jose_jws:compact( + jose_jwt:sign(Jwk, Jws, jose_jwt:from(JarmClaimsExpired)) + ), + {_, JarmTokenNotYetValid} = jose_jws:compact( + jose_jwt:sign(Jwk, Jws, jose_jwt:from(JarmClaimsNotYetValid)) + ), + {_, JarmTokenWrongSignature} = jose_jws:compact( + jose_jwt:sign(jose_jwk:generate_key({rsa, 2048}), Jws, jose_jwt:from(JarmClaims)) + ), + {_, JarmTokenWrongSignatureInvalidIssuer} = jose_jws:compact( + jose_jwt:sign( + jose_jwk:generate_key({rsa, 2048}), Jws, jose_jwt:from(JarmClaimsInvalidIssuer) + ) + ), + + ?assertMatch( + {error, {missing_claim, {<<"iss">>, Issuer}, JarmClaimsInvalidIssuer}}, + oidcc_token:validate_jarm( + JarmTokenInvalidIssuer, + ClientContext, + #{} + ) + ), + + ?assertMatch( + {error, {missing_claim, {<<"iss">>, Issuer}, JarmClaimsInvalidIssuer}}, + oidcc_token:validate_jarm( + JarmTokenWrongSignatureInvalidIssuer, + ClientContext, + #{} + ) + ), + + ?assertMatch( + {error, {missing_claim, {<<"aud">>, ClientId}, JarmClaimsExtraAudience}}, + oidcc_token:validate_jarm( + JarmTokenExtraAudience, + ClientContext, + #{trusted_audiences => []} + ) + ), + + ?assertMatch( + {ok, #{}}, + oidcc_token:validate_jarm( + JarmTokenExtraAudience, + ClientContext, + #{trusted_audiences => any} + ) + ), + + ?assertMatch( + {ok, #{}}, + oidcc_token:validate_jarm( + JarmTokenExtraAudience, + ClientContext, + #{trusted_audiences => [<<"extra">>]} + ) + ), + + ?assertMatch( + {error, {missing_claim, {<<"aud">>, ClientId}, JarmClaimsExtraAudience}}, + oidcc_token:validate_jarm( + JarmTokenExtraAudience, + ClientContext, + #{trusted_audiences => [<<"not_extra">>]} + ) + ), + + ?assertMatch( + {error, token_expired}, + oidcc_token:validate_jarm( + JarmTokenExpired, + ClientContext, + #{} + ) + ), + + ?assertMatch( + {error, token_not_yet_valid}, + oidcc_token:validate_jarm( + JarmTokenNotYetValid, + ClientContext, + #{redirect_uri => RedirectUri} + ) + ), + + ?assertMatch( + {error, no_matching_key}, + oidcc_token:validate_jarm( + JarmTokenWrongSignature, + ClientContext, + #{redirect_uri => RedirectUri} + ) + ), + + ok. + +client_context_fapi2_fixture() -> PrivDir = code:priv_dir(oidcc), - {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"), + {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/fapi2-metadata.json"), {ok, Configuration} = oidcc_provider_configuration:decode_configuration( jose:decode(ConfigurationBinary) ), - Jwk = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), + Jwk0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), + Jwk = Jwk0#jose_jwk{fields = #{<<"use">> => <<"sig">>}}, ClientJwk0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), ClientJwk = ClientJwk0#jose_jwk{ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}