From 098176a08861586a06ea731c6148badaf7cd1bf7 Mon Sep 17 00:00:00 2001 From: Paul Swartz Date: Mon, 1 Jan 2024 12:56:47 -0500 Subject: [PATCH] feat: support encrypted ID tokens and Userinfo responses --- src/oidcc_authorization.erl | 20 +++---- src/oidcc_jwt_util.erl | 86 ++++++++++++++++++++++++++++--- src/oidcc_token.erl | 31 ++++++----- src/oidcc_userinfo.erl | 73 +++++++++++++++++--------- test/oidcc_authorization_test.erl | 3 +- test/oidcc_token_test.erl | 43 +++++++++++++++- test/oidcc_userinfo_test.erl | 67 ++++++++++++++++++++++++ 7 files changed, 262 insertions(+), 61 deletions(-) diff --git a/src/oidcc_authorization.erl b/src/oidcc_authorization.erl index 7393130..6c159d9 100644 --- a/src/oidcc_authorization.erl +++ b/src/oidcc_authorization.erl @@ -252,20 +252,12 @@ attempt_request_object(QueryParams, #oidcc_client_context{ #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, + SigningJwks = oidcc_jwt_util:merge_client_secret_oct_keys( + JwksWithClientJwks, SigningAlgSupported, ClientSecret + ), + EncryptionJwks = oidcc_jwt_util:merge_client_secret_oct_keys( + JwksWithClientJwks, EncryptionAlgSupported, ClientSecret + ), MaxClockSkew = case application:get_env(oidcc, max_clock_skew) of diff --git a/src/oidcc_jwt_util.erl b/src/oidcc_jwt_util.erl index 595ef58..2ede1f1 100644 --- a/src/oidcc_jwt_util.erl +++ b/src/oidcc_jwt_util.erl @@ -12,6 +12,8 @@ -include_lib("jose/include/jose_jwt.hrl"). -export([client_secret_oct_keys/2]). +-export([merge_client_secret_oct_keys/3]). +-export([decrypt_and_verify/5]). -export([decrypt_if_needed/4]). -export([encrypt/4]). -export([evaluate_for_all_keys/2]). @@ -133,9 +135,11 @@ verify_claims(Claims, ExpClaims) -> %% @private -spec client_secret_oct_keys(AllowedAlgorithms, ClientSecret) -> jose_jwk:key() | none when AllowedAlgorithms :: [binary()] | undefined, - ClientSecret :: binary(). + ClientSecret :: binary() | unauthenticated. client_secret_oct_keys(undefined, _ClientSecret) -> none; +client_secret_oct_keys(_AllowedAlgorithms, unauthenticated) -> + none; client_secret_oct_keys(AllowedAlgorithms, ClientSecret) -> case lists:member(<<"HS256">>, AllowedAlgorithms) or @@ -143,12 +147,25 @@ client_secret_oct_keys(AllowedAlgorithms, ClientSecret) -> lists:member(<<"HS512">>, AllowedAlgorithms) of true -> - Jwk = jose_jwk:from_oct(ClientSecret), - Jwk#jose_jwk{fields = maps:merge(Jwk#jose_jwk.fields, #{<<"use">> => <<"sig">>})}; + jose_jwk:from_oct(ClientSecret); false -> none end. +%% @private +-spec merge_client_secret_oct_keys(Jwks :: jose_jwk:key(), AllowedAlgorithms, ClientSecret) -> + jose_jwk:key() +when + AllowedAlgorithms :: [binary()] | undefined, + ClientSecret :: binary() | unauthenticated. +merge_client_secret_oct_keys(Jwks, AllowedAlgorithms, ClientSecret) -> + case client_secret_oct_keys(AllowedAlgorithms, ClientSecret) of + none -> + Jwks; + OctKeys -> + merge_jwks(Jwks, OctKeys) + end. + %% @private -spec refresh_jwks_fun(ProviderConfigurationWorkerName) -> refresh_jwks_for_unknown_kid_fun() @@ -232,6 +249,35 @@ sign(Jwt, Jwk, [Algorithm | RestAlgorithms], JwsFields0) -> _ -> sign(Jwt, Jwk, RestAlgorithms, JwsFields0) end. +%% private +-spec decrypt_and_verify( + Jwt :: binary(), + Jwks :: jose_jwk:key(), + SigningAlgs :: [binary()] | undefined, + EncryptionAlgs :: [binary()] | undefined, + EncryptionEncs :: [binary()] | undefined +) -> + {ok, {#jose_jwt{}, #jose_jwe{} | #jose_jws{}}} | {error, error()}. +decrypt_and_verify(Jwt, Jwks, SigningAlgs, EncryptionAlgs, EncryptionEncs) -> + %% we call jwe_peek_protected/1 before `decrypt/4' so that we can + %% handle unencrypted tokens in the case where SupportedAlgorithms / + %% SupportedEncValues are undefined (where `decrypt/4' returns + %% {error, no_supported_alg_or_key}). + case jwe_peek_protected(Jwt) of + {ok, Jwe} -> + case decrypt(Jwt, Jwks, EncryptionAlgs, EncryptionEncs) of + {ok, Decrypted} -> + verify_decrypted_token(Decrypted, SigningAlgs, Jwe, Jwks); + {error, Reason} -> + {error, Reason} + end; + {error, not_encrypted} -> + %% signed JWT + verify_signature(Jwt, SigningAlgs, Jwks); + {error, Reason} -> + {error, Reason} + end. + %% @private -spec decrypt_if_needed( Jwt :: binary(), @@ -241,8 +287,14 @@ sign(Jwt, Jwk, [Algorithm | RestAlgorithms], JwsFields0) -> ) -> {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}; + maybe + %% we call jwe_peek_protected/1 before `decrypt/4' so that we can + %% handle unencrypted tokens in the case where SupportedAlgorithms / + %% SupportedEncValues are undefined (where `decrypt/4' returns + %% {error, no_supported_alg_or_key}). + {ok, _Jwe} ?= jwe_peek_protected(Jwt), + decrypt(Jwt, Jwk, SupportedAlgorithms, SupportedEncValues) + else {error, not_encrypted} -> {ok, Jwt}; {error, Reason} -> {error, Reason} end. @@ -308,6 +360,8 @@ decrypt(Jwt, #jose_jwk{} = Jwk, SupportedAlgorithms, SupportedEncValues) -> case Jwk of #jose_jwk{fields = #{<<"kid">> := CmpKid}} when CmpKid =/= Kid, Kid =/= none -> {error, {no_matching_key_with_kid, Kid}}; + #jose_jwk{fields = #{<<"use">> := NotEnc}} when NotEnc =/= <<"enc">> -> + {error, no_matching_key}; _ -> try {Token, _Jwe} = jose_jwe:block_decrypt(Jwk, Jwt), @@ -329,6 +383,21 @@ verify_in_list(Value, List) -> {error, no_matching_key} end. +verify_decrypted_token(Jwt, SigningAlgs, Jwe, Jwks) -> + case verify_signature(Jwt, SigningAlgs, Jwks) of + {ok, Result} -> + %% encrypted + signed (nested) JWT + {ok, Result}; + {error, invalid_jwt_token} -> + %% encrypted JWT + try + {ok, {jose_jwt:from_binary(Jwt), Jwe}} + catch + _ -> {error, invalid_jwt_token} + end; + {error, Reason} -> + {error, Reason} + end. %% @private -spec encrypt( Jwt :: binary(), @@ -359,10 +428,12 @@ encrypt(Jwt, Jwk, [_Algorithm | RestAlgorithms], SupportedEncValues, []) -> encrypt(Jwt, Jwk, [Algorithm | _RestAlgorithms] = SupportedAlgorithms, SupportedEncValues, [ EncValue | RestEncValues ]) -> + JweParams0 = #{<<"alg">> => Algorithm, <<"enc">> => EncValue}, EncryptionCallback = fun - (#jose_jwk{fields = #{<<"use">> := <<"enc">>} = Fields} = Key) -> + (#jose_jwk{fields = #{<<"use">> := NotEnc}}) when NotEnc =/= <<"enc">> -> + error; + (#jose_jwk{fields = Fields} = Key) -> try - JweParams0 = #{<<"alg">> => Algorithm, <<"enc">> => EncValue}, JweParams = case maps:get(<<"kid">>, Fields, undefined) of undefined -> JweParams0; @@ -372,6 +443,7 @@ encrypt(Jwt, Jwk, [Algorithm | _RestAlgorithms] = SupportedAlgorithms, Supported {_Jws, Token} = jose_jwe:compact(jose_jwk:block_encrypt(Jwt, Jwe, Key)), {ok, Token} catch + error:undef -> error; error:{not_supported, _Alg} -> error end; (_Key) -> diff --git a/src/oidcc_token.erl b/src/oidcc_token.erl index 4c17123..1256958 100644 --- a/src/oidcc_token.erl +++ b/src/oidcc_token.erl @@ -23,6 +23,7 @@ -include("oidcc_provider_configuration.hrl"). -include("oidcc_token.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"). @@ -895,13 +896,16 @@ validate_id_token(IdToken, ClientContext, any) -> validate_id_token(IdToken, ClientContext, Opts) when is_map(Opts) -> #oidcc_client_context{ provider_configuration = Configuration, - jwks = #jose_jwk{} = Jwks, + jwks = #jose_jwk{} = Jwks0, client_id = ClientId, - client_secret = ClientSecret + client_secret = ClientSecret, + client_jwks = ClientJwks } = ClientContext, #oidcc_provider_configuration{ id_token_signing_alg_values_supported = AllowAlgorithms, + id_token_encryption_alg_values_supported = EncryptionAlgs, + id_token_encryption_enc_values_supported = EncryptionEncs, issuer = Issuer } = Configuration, @@ -918,19 +922,16 @@ validate_id_token(IdToken, ClientContext, Opts) when is_map(Opts) -> Bin when is_binary(Bin) -> [{<<"nonce">>, Nonce} | ExpClaims0] end, - JwksInclOct = - case ClientSecret of - unauthenticated -> - Jwks; - Secret -> - case oidcc_jwt_util:client_secret_oct_keys(AllowAlgorithms, Secret) of - none -> - Jwks; - OctJwk -> - oidcc_jwt_util:merge_jwks(Jwks, OctJwk) - end + Jwks1 = + case ClientJwks of + none -> Jwks0; + #jose_jwk{} -> oidcc_jwt_util:merge_jwks(Jwks0, ClientJwks) end, - MaybeVerified = oidcc_jwt_util:verify_signature(IdToken, AllowAlgorithms, JwksInclOct), + Jwks2 = oidcc_jwt_util:merge_client_secret_oct_keys(Jwks1, AllowAlgorithms, ClientSecret), + Jwks = oidcc_jwt_util:merge_client_secret_oct_keys(Jwks2, EncryptionAlgs, ClientSecret), + MaybeVerified = oidcc_jwt_util:decrypt_and_verify( + IdToken, Jwks, AllowAlgorithms, EncryptionAlgs, EncryptionEncs + ), {ok, {#jose_jwt{fields = Claims}, Jws}} ?= case MaybeVerified of {ok, Valid} -> @@ -950,6 +951,8 @@ validate_id_token(IdToken, ClientContext, Opts) when is_map(Opts) -> #jose_jws{alg = {jose_jws_alg_none, none}} -> {error, {none_alg_used, Claims}}; #jose_jws{} -> + {ok, Claims}; + #jose_jwe{} -> {ok, Claims} end end. diff --git a/src/oidcc_userinfo.erl b/src/oidcc_userinfo.erl index f063826..71a78c3 100644 --- a/src/oidcc_userinfo.erl +++ b/src/oidcc_userinfo.erl @@ -17,7 +17,9 @@ -include("oidcc_provider_configuration.hrl"). -include("oidcc_token.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"). -export([retrieve/3]). @@ -188,24 +190,33 @@ validate_userinfo_body({json, Userinfo}, _ClientContext, Opts) -> {ExpectedSubject, #{<<"sub">> := ExpectedSubject} = Map} -> {ok, Map}; {_, #{}} -> {error, bad_subject} end; -validate_userinfo_body({jwt, UserinfoBody}, ClientContext, Opts) -> +validate_userinfo_body({jwt, UserinfoBody}, ClientContext, Opts0) -> #oidcc_client_context{provider_configuration = Configuration, client_id = ClientId} = ClientContext, #oidcc_provider_configuration{issuer = Issuer} = Configuration, - ExpectedSubject = maps:get(expected_subject, Opts), - ExpectedClaims0 = [ + ExpectedSubject = maps:get(expected_subject, Opts0), + %% only validate these claims if the token is signed: + %% https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.3.2 + ExpectedSignedClaims = [ {<<"aud">>, ClientId}, {<<"iss">>, Issuer} ], ExpectedClaims = - case maps:get(expected_subject, Opts) of - any -> ExpectedClaims0; - ExpectedSubject -> [{<<"sub">>, ExpectedSubject} | ExpectedClaims0] + case maps:get(expected_subject, Opts0) of + any -> []; + ExpectedSubject -> [{<<"sub">>, ExpectedSubject}] end, + Opts = maps:merge( + #{ + expected_signed_claims => ExpectedSignedClaims, + expected_claims => ExpectedClaims + }, + Opts0 + ), validate_userinfo_token( UserinfoBody, ClientContext, - maps:put(expected_claims, ExpectedClaims, Opts) + Opts ). -spec validate_userinfo_token(Token, ClientContext, Opts) -> @@ -217,45 +228,59 @@ when #{ refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(), expected_subject => binary(), + expected_signed_claims => [{binary(), term()}], expected_claims => [{binary(), term()}] }, Claims :: oidcc_jwt_util:claims(). validate_userinfo_token(UserinfoToken, ClientContext, Opts) -> RefreshJwksFun = maps:get(refresh_jwks, Opts, undefined), - ExpClaims = maps:get(expected_claims, Opts, []), #oidcc_client_context{ provider_configuration = Configuration, - jwks = #jose_jwk{} = Jwks, + jwks = #jose_jwk{} = Jwks0, client_id = ClientId, - client_secret = ClientSecret + client_secret = ClientSecret, + client_jwks = ClientJwks } = ClientContext, #oidcc_provider_configuration{ userinfo_signing_alg_values_supported = AllowAlgorithms, + userinfo_encryption_alg_values_supported = EncryptionAlgs, + userinfo_encryption_enc_values_supported = EncryptionEncs, issuer = Issuer } = Configuration, maybe - JwksInclOct = - case ClientSecret of - unauthenticated -> - Jwks; - Secret -> - case oidcc_jwt_util:client_secret_oct_keys(AllowAlgorithms, Secret) of - none -> - Jwks; - OctJwk -> - oidcc_jwt_util:merge_jwks(Jwks, OctJwk) - end + Jwks1 = oidcc_jwt_util:merge_client_secret_oct_keys(Jwks0, AllowAlgorithms, ClientSecret), + Jwks2 = oidcc_jwt_util:merge_client_secret_oct_keys(Jwks1, EncryptionAlgs, ClientSecret), + Jwks = + case ClientJwks of + #jose_jwk{} -> + oidcc_jwt_util:merge_jwks(Jwks2, ClientJwks); + _ -> + Jwks2 + end, + {ok, {#jose_jwt{fields = Claims}, JwsOrJwe}} ?= + oidcc_jwt_util:decrypt_and_verify( + UserinfoToken, + Jwks, + AllowAlgorithms, + EncryptionAlgs, + EncryptionEncs + ), + ExpClaims = + case JwsOrJwe of + #jose_jws{} -> + maps:get(expected_claims, Opts, []) ++ + maps:get(expected_signed_claims, Opts, []); + #jose_jwe{} -> + maps:get(expected_claims, Opts, []) end, - {ok, {#jose_jwt{fields = Claims}, _Jws}} ?= - oidcc_jwt_util:verify_signature(UserinfoToken, AllowAlgorithms, JwksInclOct), ok ?= oidcc_jwt_util:verify_claims(Claims, ExpClaims), {ok, maps:remove(nonce, Claims)} else {error, {no_matching_key_with_kid, Kid}} when RefreshJwksFun =/= undefined -> maybe - {ok, RefreshedJwks} ?= RefreshJwksFun(Jwks, Kid), + {ok, RefreshedJwks} ?= RefreshJwksFun(Jwks0, Kid), RefreshedClientContext = ClientContext#oidcc_client_context{jwks = RefreshedJwks}, validate_userinfo_token(UserinfoToken, RefreshedClientContext, Opts) end; diff --git a/test/oidcc_authorization_test.erl b/test/oidcc_authorization_test.erl index c7b6e68..518bc2a 100644 --- a/test/oidcc_authorization_test.erl +++ b/test/oidcc_authorization_test.erl @@ -360,7 +360,8 @@ create_redirect_url_with_request_object_no_hmac_test() -> ClientId = <<"client_id">>, ClientSecret = <<"">>, - Jwks = jose_jwk:to_public(jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem")), + Jwks0 = jose_jwk:to_public(jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem")), + Jwks = Jwks0#jose_jwk{fields = #{<<"use">> => <<"sig">>}}, ClientJwks0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"), ClientJwks = ClientJwks0#jose_jwk{fields = #{<<"use">> => <<"sig">>}}, diff --git a/test/oidcc_token_test.erl b/test/oidcc_token_test.erl index fcbb396..f4084a4 100644 --- a/test/oidcc_token_test.erl +++ b/test/oidcc_token_test.erl @@ -1890,6 +1890,47 @@ validate_jarm_invalid_token_test() -> ok. +validate_id_token_encrypted_token_test() -> + #oidcc_client_context{client_id = ClientId, jwks = Jwk, provider_configuration = Configuration0} = + ClientContext0 = client_context_fapi2_fixture(), + + #oidcc_provider_configuration{issuer = Issuer} = + Configuration = Configuration0#oidcc_provider_configuration{ + token_endpoint_auth_methods_supported = [<<"private_key_jwt">>], + token_endpoint_auth_signing_alg_values_supported = [<<"RS256">>], + id_token_encryption_alg_values_supported = [<<"RSA-OAEP">>], + id_token_encryption_enc_values_supported = [<<"A256GCM">>] + }, + + ClientContext = ClientContext0#oidcc_client_context{provider_configuration = Configuration}, + + Claims = + #{ + <<"iss">> => Issuer, + <<"sub">> => <<"sub">>, + <<"aud">> => ClientId, + <<"iat">> => erlang:system_time(second), + <<"exp">> => erlang:system_time(second) + 10, + <<"at_hash">> => <<"hrOQHuo3oE6FR82RIiX1SA">> + }, + + Jwt = jose_jwt:from(Claims), + Jws = #{<<"alg">> => <<"RS256">>}, + {_Jws, Token0} = + jose_jws:compact( + jose_jwt:sign(Jwk, Jws, Jwt) + ), + Jwe = #{<<"alg">> => <<"RSA-OAEP">>, <<"enc">> => <<"A256GCM">>}, + {_Jwe, Token} = + jose_jwe:compact(jose_jwk:block_encrypt(Token0, Jwe, Jwk)), + + ?assertEqual( + {ok, Claims}, + oidcc_token:validate_id_token(Token, ClientContext, #{}) + ), + + ok. + client_context_fapi2_fixture() -> PrivDir = code:priv_dir(oidcc), @@ -1902,7 +1943,7 @@ client_context_fapi2_fixture() -> 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">>} + fields = #{<<"kid">> => <<"private_kid">>} }, ClientId = <<"client_id">>, diff --git a/test/oidcc_userinfo_test.erl b/test/oidcc_userinfo_test.erl index 2aa3bb9..1892c1f 100644 --- a/test/oidcc_userinfo_test.erl +++ b/test/oidcc_userinfo_test.erl @@ -207,6 +207,73 @@ jwt_test() -> ok. +jwt_encrypted_not_signed_test() -> + PrivDir = code:priv_dir(oidcc), + + {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"), + {ok, + #oidcc_provider_configuration{} = + Configuration} = + oidcc_provider_configuration:decode_configuration(jose:decode(ConfigurationBinary)), + + Jwk = jose_jwk:generate_key({rsa, 1024}), + + ClientId = <<"client_id">>, + ClientSecret = <<"client_secret">>, + + ClientContext = oidcc_client_context:from_manual( + Configuration, Jwk, ClientId, ClientSecret + ), + + Sub = <<"123456">>, + + %% iss and aud claims are only required if the token is signed; not encrypted. + %% https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.3.2 + %% If signed, the UserInfo Response MUST contain the Claims iss (issuer) + %% and aud (audience) as members. The iss value MUST be the OP's Issuer + %% Identifier URL. The aud value MUST be or include the RP's Client ID + %% value. + {_, UserinfoJwt} = jose_jwt:to_binary(#{ + <<"name">> => <<"joe">>, + <<"sub">> => Sub, + <<"iat">> => erlang:system_time(second), + <<"exp">> => erlang:system_time(second) + 10 + }), + UserinfoJwe = #{<<"alg">> => <<"RSA-OAEP">>, <<"enc">> => <<"A256GCM">>}, + + {_Jwe, HttpBody} = + jose_jwe:compact( + jose_jwk:block_encrypt(UserinfoJwt, UserinfoJwe, Jwk) + ), + + HttpFun = + fun(get, {_Url, _Header}, _HttpOpts, _Opts) -> + {ok, {{"HTTP/1.1", 200, "OK"}, [{"content-type", "application/jwt"}], HttpBody}} + end, + ok = meck:new(httpc), + ok = meck:expect(httpc, request, HttpFun), + + AccessToken = <<"opensesame">>, + Token = + #oidcc_token{ + access = #oidcc_token_access{token = AccessToken}, + id = + #oidcc_token_id{ + token = "id_token", + claims = #{<<"sub">> => Sub} + } + }, + + ?assertMatch( + {ok, #{<<"name">> := <<"joe">>}}, + oidcc_userinfo:retrieve(Token, ClientContext, #{}) + ), + + true = meck:validate(httpc), + meck:unload(httpc), + + ok. + distributed_claims_test() -> PrivDir = code:priv_dir(oidcc),