Skip to content

Commit

Permalink
feat: support encrypted ID tokens and Userinfo responses (erlef#326)
Browse files Browse the repository at this point in the history
  • Loading branch information
paulswartz authored Jan 4, 2024
1 parent 140b788 commit 37a1361
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 61 deletions.
20 changes: 6 additions & 14 deletions src/oidcc_authorization.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 79 additions & 7 deletions src/oidcc_jwt_util.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).
Expand Down Expand Up @@ -133,22 +135,37 @@ 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
lists:member(<<"HS384">>, AllowedAlgorithms) or
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()
Expand Down Expand Up @@ -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(),
Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
Expand All @@ -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) ->
Expand Down
31 changes: 17 additions & 14 deletions src/oidcc_token.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down Expand Up @@ -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,
Expand All @@ -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} ->
Expand All @@ -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.
Expand Down
73 changes: 49 additions & 24 deletions src/oidcc_userinfo.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).
Expand Down Expand Up @@ -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) ->
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion test/oidcc_authorization_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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">>}},
Expand Down
Loading

0 comments on commit 37a1361

Please sign in to comment.