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">>}