Skip to content

Commit

Permalink
feat: JARM
Browse files Browse the repository at this point in the history
  • Loading branch information
paulswartz committed Dec 30, 2023
1 parent b43a0ee commit 630e02c
Show file tree
Hide file tree
Showing 10 changed files with 624 additions and 37 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions lib/oidcc/token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions priv/test/fixtures/fapi2-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
"code token id_token",
"none"
],
"response_modes_supported": [
"query",
"fragment",
"jwt",
"query.jwt"
],
"subject_types_supported": [
"public"
],
Expand Down Expand Up @@ -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"
]
}
25 changes: 17 additions & 8 deletions src/oidcc_authorization.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
%%
Expand All @@ -42,6 +43,7 @@
%% <li>`require_pkce' - whether to require PKCE when getting the token</li>
%% <li>`redirect_uri' - redirect target after authorization is completed</li>
%% <li>`url_extension' - add custom query parameters to the authorization url</li>
%% <li>`response_mode' - response mode to use (defaults to `<<"query">>')</li>
%% </ul>

-type error() ::
Expand Down Expand Up @@ -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) ->
Expand Down
134 changes: 132 additions & 2 deletions src/oidcc_jwt_util.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).
Expand All @@ -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()}.

Expand Down Expand Up @@ -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
Expand All @@ -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(),
Expand Down Expand Up @@ -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.
Loading

0 comments on commit 630e02c

Please sign in to comment.