From 4ccf5fedb02b28b46a1bce51e84ba86caf781253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20M=C3=A4nnchen?= Date: Wed, 20 Sep 2023 12:36:28 +0200 Subject: [PATCH] Support JWT authorization request parameter (#232) --- src/oidcc_authorization.erl | 110 +++++++++++++++++++++++++++- src/oidcc_jwt_util.erl | 103 +++++++++++++++++++++++++- test/oidcc_authorization_test.erl | 117 ++++++++++++++++++++++++++++++ test/oidcc_token_test.erl | 2 +- 4 files changed, 327 insertions(+), 5 deletions(-) diff --git a/src/oidcc_authorization.erl b/src/oidcc_authorization.erl index cdece46..5048b17 100644 --- a/src/oidcc_authorization.erl +++ b/src/oidcc_authorization.erl @@ -10,6 +10,8 @@ -include("oidcc_client_context.hrl"). -include("oidcc_provider_configuration.hrl"). +-include_lib("jose/include/jose_jwk.hrl"). + -export([create_redirect_url/2]). -export_type([error/0]). @@ -94,7 +96,7 @@ create_redirect_url(#oidcc_client_context{} = ClientContext, Opts) -> -spec redirect_params(ClientContext, Opts) -> oidcc_http_util:query_params() when ClientContext :: oidcc_client_context:t(), Opts :: opts(). -redirect_params(#oidcc_client_context{client_id = ClientId}, Opts) -> +redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opts) -> QueryParams = [ {<<"response_type">>, maps:get(response_type, Opts, <<"code">>)}, @@ -106,9 +108,10 @@ redirect_params(#oidcc_client_context{client_id = ClientId}, Opts) -> QueryParams2 = maybe_append(<<"nonce">>, maps:get(nonce, Opts, undefined), QueryParams1), QueryParams3 = append_code_challenge(maps:get(pkce, Opts, undefined), QueryParams2), - oidcc_scope:query_append_scope( + QueryParams4 = oidcc_scope:query_append_scope( maps:get(scopes, Opts, [openid]), QueryParams3 - ). + ), + attempt_request_object(QueryParams4, ClientContext). -spec append_code_challenge( Pkce :: pkce() | undefined, QueryParams :: oidcc_http_util:query_params() @@ -127,3 +130,104 @@ maybe_append(_Key, undefined, QueryParams) -> QueryParams; maybe_append(Key, Value, QueryParams) -> [{Key, Value} | QueryParams]. + +-spec attempt_request_object(QueryParams, ClientContext) -> QueryParams 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_id = ClientId, + client_secret = ClientSecret, + provider_configuration = #oidcc_provider_configuration{ + issuer = Issuer, + request_parameter_supported = true, + request_object_signing_alg_values_supported = SigningAlgSupported, + request_object_encryption_alg_values_supported = EncryptionAlgSupported, + request_object_encryption_enc_values_supported = EncryptionEncSupported + }, + jwks = Jwks +}) -> + SigningJwks = + case oidcc_jwt_util:client_secret_oct_keys(SigningAlgSupported, ClientSecret) of + none -> + Jwks; + SigningOctJwk -> + oidcc_jwt_util:merge_jwks(Jwks, SigningOctJwk) + end, + EncryptionJwks = + case oidcc_jwt_util:client_secret_oct_keys(EncryptionAlgSupported, ClientSecret) of + none -> + Jwks; + EncryptionOctJwk -> + oidcc_jwt_util:merge_jwks(Jwks, EncryptionOctJwk) + end, + + MaxClockSkew = + case application:get_env(oidcc, max_clock_skew) of + undefined -> 0; + ClockSkew -> ClockSkew + end, + + Claims = maps:merge( + #{ + <<"iss">> => ClientId, + <<"aud">> => Issuer, + <<"jti">> => random_string(32), + <<"iat">> => os:system_time(seconds), + <<"exp">> => os:system_time(seconds) + 30, + <<"nbf">> => os:system_time(seconds) - MaxClockSkew + }, + maps:from_list(QueryParams) + ), + Jwt = jose_jwt:from(Claims), + + case oidcc_jwt_util:sign(Jwt, SigningJwks, deprioritize_none_alg(SigningAlgSupported)) of + {error, no_supported_alg_or_key} -> + QueryParams; + {ok, SignedRequestObject} -> + case + oidcc_jwt_util:encrypt( + SignedRequestObject, + EncryptionJwks, + deprioritize_none_alg(EncryptionAlgSupported), + EncryptionEncSupported + ) + of + {ok, EncryptedRequestObject} -> + [{<<"request">>, EncryptedRequestObject} | essential_params(QueryParams)]; + {error, no_supported_alg_or_key} -> + [{<<"request">>, SignedRequestObject} | essential_params(QueryParams)] + end + end. + +-spec essential_params(QueryParams :: oidcc_http_util:query_params()) -> + oidcc_http_util:query_params(). +essential_params(QueryParams) -> + lists:filter( + fun + ({<<"scope">>, _Value}) -> true; + ({<<"response_type">>, _Value}) -> true; + ({<<"client_id">>, _Value}) -> true; + ({<<"redirect_uri">>, _Value}) -> true; + (_Other) -> false + end, + QueryParams + ). + +-spec deprioritize_none_alg(Algorithms :: [binary()]) -> [binary()]. +deprioritize_none_alg(Algorithms) -> + lists:usort( + fun + (<<"none">>, _B) -> false; + (_A, <<"none">>) -> true; + (_A, _B) -> true + end, + Algorithms + ). + +-spec random_string(Bytes :: pos_integer()) -> binary(). +random_string(Bytes) -> + base64:encode(crypto:strong_rand_bytes(Bytes), #{mode => urlsafe, padding => false}). diff --git a/src/oidcc_jwt_util.erl b/src/oidcc_jwt_util.erl index 15865e4..6c5b059 100644 --- a/src/oidcc_jwt_util.erl +++ b/src/oidcc_jwt_util.erl @@ -9,8 +9,11 @@ -include_lib("jose/include/jose_jwt.hrl"). -export([client_secret_oct_keys/2]). +-export([encrypt/4]). +-export([evaluate_for_all_keys/2]). -export([merge_jwks/2]). -export([refresh_jwks_fun/1]). +-export([sign/3]). -export([verify_claims/2]). -export([verify_signature/3]). @@ -124,7 +127,8 @@ client_secret_oct_keys(AllowedAlgorithms, ClientSecret) -> lists:member(<<"HS512">>, AllowedAlgorithms) of true -> - jose_jwk:from_oct(ClientSecret); + Jwk = jose_jwk:from_oct(ClientSecret), + Jwk#jose_jwk{fields = maps:merge(Jwk#jose_jwk.fields, #{<<"use">> => <<"sig">>})}; false -> none end. @@ -155,3 +159,100 @@ merge_jwks(#jose_jwk{} = Left, #jose_jwk{keys = {jose_jwk_set, _RightKeys}} = Ri merge_jwks(#jose_jwk{keys = {jose_jwk_set, [Left]}}, Right); merge_jwks(Left, Right) -> merge_jwks(Left, #jose_jwk{keys = {jose_jwk_set, [Right]}}). + +%% @private +-spec sign(Jwt :: #jose_jwt{}, Jwk :: jose_jwk:key(), SupportedAlgorithms :: [binary()]) -> + {ok, binary()} | {error, no_supported_alg_or_key}. +sign(_Jwt, _Jwk, []) -> + {error, no_supported_alg_or_key}; +sign(Jwt, Jwk, [Algorithm | RestAlgorithms]) -> + Jws = jose_jws:from_map(#{<<"alg">> => Algorithm}), + SigningCallback = fun + (#jose_jwk{fields = #{<<"use">> := <<"sig">>}} = Key) -> + try + {_Jws, Token} = jose_jws:compact(jose_jwt:sign(Key, Jws, Jwt)), + {ok, Token} + catch + error:not_supported -> error; + error:{not_supported, _Alg} -> error; + %% Some Keys crash if a public key is provided + error:function_clause -> error + end; + (#jose_jwk{} = Key) when Algorithm == <<"none">> -> + {_Jws, Token} = jose_jws:compact(jose_jwt:sign(Key, Jws, Jwt)), + {ok, Token}; + (_Key) -> + error + end, + case evaluate_for_all_keys(Jwk, SigningCallback) of + {ok, Token} -> {ok, Token}; + error -> sign(Jwt, Jwk, RestAlgorithms) + end. + +%% @private +-spec encrypt( + Jwt :: binary(), + Jwk :: jose_jwk:key(), + SupportedAlgorithms :: [binary()], + SupportedEncValues :: [binary()] +) -> + {ok, binary()} | {error, no_supported_alg_or_key}. +encrypt(Jwt, Jwk, SupportedAlgorithms, SupportedEncValues) -> + encrypt(Jwt, Jwk, SupportedAlgorithms, SupportedEncValues, SupportedEncValues). + +-spec encrypt( + Jwt :: binary(), + Jwk :: jose_jwk:key(), + SupportedAlgorithms :: [binary()], + SupportedEncValues :: [binary()], + AccEncValues :: [binary()] +) -> + {ok, binary()} | {error, no_supported_alg_or_key}. +encrypt(_Jwt, _Jwk, [], _SupportedEncValues, _AccEncValues) -> + {error, no_supported_alg_or_key}; +encrypt(Jwt, Jwk, [_Algorithm | RestAlgorithms], SupportedEncValues, []) -> + encrypt(Jwt, Jwk, RestAlgorithms, SupportedEncValues, SupportedEncValues); +encrypt(Jwt, Jwk, [Algorithm | _RestAlgorithms] = SupportedAlgorithms, SupportedEncValues, [ + EncValue | RestEncValues +]) -> + EncryptionCallback = fun + (#jose_jwk{fields = #{<<"use">> := <<"enc">>} = Fields} = Key) -> + try + JweParams0 = #{<<"alg">> => Algorithm, <<"enc">> => EncValue}, + JweParams = + case maps:get(<<"kid">>, Fields, undefined) of + undefined -> JweParams0; + Kid -> maps:put(<<"kid">>, Kid, JweParams0) + end, + Jwe = jose_jwe:from_map(JweParams), + {_Jws, Token} = jose_jwe:compact(jose_jwk:block_encrypt(Jwt, Jwe, Key)), + {ok, Token} + catch + error:{not_supported, _Alg} -> error + end; + (_Key) -> + error + end, + case evaluate_for_all_keys(Jwk, EncryptionCallback) of + {ok, Token} -> {ok, Token}; + error -> encrypt(Jwt, Jwk, SupportedAlgorithms, SupportedEncValues, RestEncValues) + end. + +%% @private +-spec evaluate_for_all_keys(Jwk :: jose_jwk:key(), fun((jose_jwk:key()) -> {ok, Result} | error)) -> + {ok, Result} | error +when + Result :: term(). +evaluate_for_all_keys(#jose_jwk{keys = {jose_jwk_set, Keys}}, Callback) -> + lists:foldl( + fun + (_Key, {ok, Result}) -> + {ok, Result}; + (Key, error) -> + evaluate_for_all_keys(Key, Callback) + end, + error, + Keys + ); +evaluate_for_all_keys(#jose_jwk{} = Jwk, Callback) -> + Callback(Jwk). diff --git a/test/oidcc_authorization_test.erl b/test/oidcc_authorization_test.erl index 348e3b7..2899f65 100644 --- a/test/oidcc_authorization_test.erl +++ b/test/oidcc_authorization_test.erl @@ -1,6 +1,11 @@ -module(oidcc_authorization_test). -include_lib("eunit/include/eunit.hrl"). +-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"). +-include_lib("oidcc/include/oidcc_provider_configuration.hrl"). create_redirect_url_test() -> PrivDir = code:priv_dir(oidcc), @@ -80,3 +85,115 @@ create_redirect_url_test() -> ?assertEqual(ExpUrl6, iolist_to_binary(Url6)), ok. + +create_redirect_url_with_request_object_test() -> + PrivDir = code:priv_dir(oidcc), + + %% Enable none algorithm for test + jose:unsecured_signing(true), + + {ok, ValidConfigString} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"), + {ok, #oidcc_provider_configuration{issuer = Issuer} = Configuration0} = oidcc_provider_configuration:decode_configuration( + jose:decode(ValidConfigString) + ), + + Configuration = Configuration0#oidcc_provider_configuration{ + request_parameter_supported = true, + request_object_signing_alg_values_supported = [ + <<"none">>, + <<"HS256">>, + <<"RS256">>, + <<"PS256">>, + <<"ES256">>, + <<"EdDSA">> + ], + request_object_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">> + ], + request_object_encryption_enc_values_supported = [ + <<"A128CBC-HS256">>, + <<"A192CBC-HS384">>, + <<"A256CBC-HS512">>, + <<"A128GCM">>, + <<"A192GCM">>, + <<"A256GCM">> + ] + }, + + 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">> => <<"enc">>}}, + + RedirectUri = <<"https://my.server/return">>, + + ClientContext = + oidcc_client_context:from_manual(Configuration, Jwks, ClientId, ClientSecret), + + {ok, Url} = oidcc_authorization:create_redirect_url(ClientContext, #{ + redirect_uri => RedirectUri + }), + + ?assertMatch(<<"https://my.provider/auth?request=", _/binary>>, iolist_to_binary(Url)), + + #{query := QueryString} = uri_string:parse(Url), + QueryParams0 = uri_string:dissect_query(QueryString), + QueryParams1 = lists:map( + fun({Key, Value}) -> {list_to_binary(Key), list_to_binary(Value)} end, QueryParams0 + ), + QueryParams = maps:from_list(QueryParams1), + + ?assertMatch( + #{ + <<"client_id">> := <<"client_id">>, + <<"redirect_uri">> := <<"https://my.server/return">>, + <<"response_type">> := <<"code">>, + <<"scope">> := <<"openid">>, + <<"request">> := _ + }, + QueryParams + ), + + {SignedToken, Jwe} = jose_jwe:block_decrypt(Jwks, maps:get(<<"request">>, QueryParams)), + + ?assertMatch(#jose_jwe{alg = {jose_jwe_alg_rsa, _}}, Jwe), + + {true, Jwt, Jws} = jose_jwt:verify(jose_jwk:from_oct(ClientSecret), SignedToken), + + ?assertMatch(#jose_jws{alg = {jose_jws_alg_hmac, 'HS256'}}, Jws), + + ?assertMatch( + #jose_jwt{ + fields = #{ + <<"aud">> := Issuer, + <<"client_id">> := ClientId, + <<"exp">> := _, + <<"iat">> := _, + <<"iss">> := ClientId, + <<"jti">> := _, + <<"nbf">> := _, + <<"redirect_uri">> := RedirectUri, + <<"response_type">> := <<"code">>, + <<"scope">> := <<"openid">> + } + }, + Jwt + ), + + ok. diff --git a/test/oidcc_token_test.erl b/test/oidcc_token_test.erl index 4bf6e87..dd2eb5a 100644 --- a/test/oidcc_token_test.erl +++ b/test/oidcc_token_test.erl @@ -8,7 +8,7 @@ retrieve_none_test() -> PrivDir = code:priv_dir(oidcc), - %% Enable none algorythm for test + %% Enable none algorithm for test jose:unsecured_signing(true), {ok, _} = application:ensure_all_started(oidcc),