Skip to content

Commit

Permalink
feat: DPoP
Browse files Browse the repository at this point in the history
  • Loading branch information
paulswartz committed Dec 19, 2023
1 parent dcd823f commit d28a6dc
Show file tree
Hide file tree
Showing 15 changed files with 1,315 additions and 41 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)
* [Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449)

## Setup

Expand Down
2 changes: 2 additions & 0 deletions include/oidcc_client_registration.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
post_logout_redirect_uris = undefined :: [uri_string:uri_string()] | undefined,
%% OAuth 2.0 Pushed Authorization Requests
require_pushed_authorization_requests = false :: boolean(),
%% OAuth 2.0 Demonstrating Proof of Possession (DPoP)
dpop_bound_access_tokens = false :: boolean(),
%% Unknown Fields
extra_fields = #{} :: #{binary() => term()}
}).
Expand Down
1 change: 1 addition & 0 deletions lib/oidcc/client_registration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ defmodule Oidcc.ClientRegistration do
request_uris: [:uri_string.uri_string()] | :undefined,
post_logout_redirect_uris: [:uri_string.uri_string()] | :undefined,
require_pushed_authorization_requests: boolean(),
dpop_bound_access_tokens: boolean(),
extra_fields: %{String.t() => term()}
}

Expand Down
25 changes: 25 additions & 0 deletions lib/oidcc/token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,31 @@ defmodule Oidcc.Token do
|> :oidcc_token.client_credentials(opts)
|> normalize_token_response()

@doc """
Generate a map of authorization headers to use when using the given
`%Oidcc.Token.Access{}` to access an API endpoint.
"""
@doc since: "3.2.0"
@spec authorization_headers(
access_token :: Access.t(),
method :: :get | :post,
endpoint :: String.t(),
client_context :: ClientContext.t()
) :: %{String.t() => String.t()}
def authorization_headers(
access_token,
method,
endpoint,
client_context
),
do:
:oidcc_token.authorization_headers(
Access.struct_to_record(access_token),
method,
endpoint,
ClientContext.struct_to_record(client_context)
)

@doc false
@spec normalize_token_response(
response :: {:ok, :oidcc_token.t()} | {:error, :oidcc_token.error()}
Expand Down
105 changes: 105 additions & 0 deletions src/oidcc_auth_util.erl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
-type error() :: no_supported_auth_method.

-export([add_client_authentication/6]).
-export([add_dpop_proof_header/5]).
-export([add_authorization_header/6]).

%% @private
-spec add_client_authentication(
Expand Down Expand Up @@ -251,6 +253,109 @@ add_jwt_bearer_assertion(ClientAssertion, Body, Header, ClientContext) ->
Header
}.

%% @private
-spec add_dpop_proof_header(Header, Method, Endpoint, Opts, ClientContext) -> Header when
Header :: [oidcc_http_util:http_header()],
Method :: post | get,
Endpoint :: uri_string:uri_string(),
Opts :: #{nonce => binary()},
ClientContext :: oidcc_client_context:t().
add_dpop_proof_header(Header, Method, Endpoint, Opts, ClientContext) ->
Claims =
case Opts of
#{nonce := Nonce} ->
#{<<"nonce">> => Nonce};
_ ->
#{}
end,
case dpop_proof(Method, Endpoint, Claims, ClientContext) of
{ok, SignedRequestObject} ->
[{"dpop", SignedRequestObject} | Header];
error ->
Header
end.

%% @private
-spec add_authorization_header(
AccessToken, AccessTokenType, Method, Endpoint, Opts, ClientContext
) ->
Header
when
AccessToken :: binary(),
AccessTokenType :: binary(),
Method :: post | get,
Endpoint :: uri_string:uri_string(),
Opts :: #{dpop_nonce => binary()},
ClientContext :: oidcc_client_context:t(),
Header :: [oidcc_http_util:http_header()].
add_authorization_header(
AccessToken, AccessTokenType, Method, Endpoint, Opts, ClientContext
) ->
maybe
true ?= string:casefold(<<"dpop">>) =:= string:casefold(AccessTokenType),
Claims0 =
case Opts of
#{dpop_nonce := Nonce} ->
#{<<"nonce">> => Nonce};
_ ->
#{}
end,
Claims = Claims0#{
<<"ath">> => base64:encode(crypto:hash(sha256, AccessToken), #{
mode => urlsafe, padding => false
})
},
{ok, SignedRequestObject} ?= dpop_proof(Method, Endpoint, Claims, ClientContext),
[
{"authorization", [AccessTokenType, <<" ">>, AccessToken]},
{"dpop", SignedRequestObject}
]
else
_ ->
[oidcc_http_util:bearer_auth_header(AccessToken)]
end.

-spec dpop_proof(Method, Endpoint, Claims, ClientContext) -> {ok, binary()} | error when
Method :: post | get,
Endpoint :: uri_string:uri_string(),
Claims :: map(),
ClientContext :: oidcc_client_context:t().
dpop_proof(Method, Endpoint, Claims0, #oidcc_client_context{
client_jwks = #jose_jwk{} = ClientJwks,
provider_configuration = #oidcc_provider_configuration{
dpop_signing_alg_values_supported = [_ | _] = SigningAlgSupported
}
}) ->
MaxClockSkew =
case application:get_env(oidcc, max_clock_skew) of
undefined -> 0;
{ok, ClockSkew} -> ClockSkew
end,
HtmClaim = string:uppercase(atom_to_binary(Method, utf8)),
Claims = Claims0#{
<<"jti">> => random_string(32),
<<"htm">> => HtmClaim,
<<"htu">> => iolist_to_binary(Endpoint),
<<"iat">> => os:system_time(seconds),
<<"exp">> => os:system_time(seconds) + 30,
<<"nbf">> => os:system_time(seconds) - MaxClockSkew
},
Jwt = jose_jwt:from(Claims),
{_, PublicJwk} = jose_jwk:to_public_map(ClientJwks),

case
oidcc_jwt_util:sign(Jwt, ClientJwks, SigningAlgSupported, #{
<<"typ">> => <<"dpop+jwt">>, <<"jwk">> => PublicJwk
})
of
{ok, SignedRequestObject} ->
{ok, SignedRequestObject};
{error, no_supported_alg_or_key} ->
error
end;
dpop_proof(_Method, _Endpoint, _Claims, _ClientContext) ->
error.

-spec random_string(Bytes :: pos_integer()) -> binary().
random_string(Bytes) ->
base64:encode(crypto:strong_rand_bytes(Bytes), #{mode => urlsafe, padding => false}).
25 changes: 23 additions & 2 deletions src/oidcc_authorization.erl
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opt
QueryParams4 = oidcc_scope:query_append_scope(
maps:get(scopes, Opts, [openid]), QueryParams3
),
QueryParams5 = attempt_request_object(QueryParams4, ClientContext),
attempt_par(QueryParams5, ClientContext, Opts).
QueryParams5 = maybe_append_dpop_jkt(QueryParams4, ClientContext),
QueryParams6 = attempt_request_object(QueryParams5, ClientContext),
attempt_par(QueryParams6, ClientContext, Opts).

-spec append_code_challenge(PkceVerifier, QueryParams, ClientContext) ->
oidcc_http_util:query_params()
Expand Down Expand Up @@ -164,6 +165,26 @@ maybe_append(_Key, undefined, QueryParams) ->
maybe_append(Key, Value, QueryParams) ->
[{Key, Value} | QueryParams].

-spec maybe_append_dpop_jkt(QueryParams, ClientContext) ->
QueryParams
when
ClientContext :: oidcc_client_context:t(),
QueryParams :: oidcc_http_util:query_params().
maybe_append_dpop_jkt(
QueryParams,
#oidcc_client_context{
client_jwks = #jose_jwk{},
provider_configuration = #oidcc_provider_configuration{
dpop_signing_alg_values_supported = [_ | _]
}
} = ClientContext
) ->
#oidcc_client_context{client_jwks = ClientJwks} = ClientContext,
Thumbprint = jose_jwk:thumbprint(ClientJwks),
[{"dpop_jkt", Thumbprint} | QueryParams];
maybe_append_dpop_jkt(QueryParams, _ClientContext) ->
QueryParams.

-spec attempt_request_object(QueryParams, ClientContext) -> QueryParams when
QueryParams :: oidcc_http_util:query_params(),
ClientContext :: oidcc_client_context:t().
Expand Down
2 changes: 2 additions & 0 deletions src/oidcc_client_registration.erl
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@
post_logout_redirect_uris :: [uri_string:uri_string()] | undefined,
%% OAuth 2.0 Pushed Authorization Requests
require_pushed_authorization_requests :: boolean(),
%% OAuth 2.0 Demonstrating Proof of Possession (DPoP)
dpop_bound_access_tokens :: boolean(),
%% Unknown Fields
extra_fields :: #{binary() => term()}
}.
Expand Down
10 changes: 8 additions & 2 deletions src/oidcc_http_util.erl
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
-type http_header() :: {Field :: [byte()] | binary(), Value :: iodata()}.
%% See {@link httpc:request/5}
-type error() ::
{http_error, StatusCode :: pos_integer(), HttpBodyResult :: binary()}
{http_error, StatusCode :: pos_integer(), HttpBodyResult :: binary() | map()}
| {use_dpop_nonce, Nonce :: binary(), HttpBodyResult :: binary() | map()}
| invalid_content_type
| httpc_error().
-type httpc_error() :: term().
Expand Down Expand Up @@ -147,7 +148,12 @@ extract_successful_response({{_HttpVersion, StatusCode, _HttpStatusName}, Header
unknown ->
HttpBodyResult
end,
{error, {http_error, StatusCode, Body}}.
case proplists:lookup("dpop-nonce", Headers) of
{"dpop-nonce", DpopNonce} ->
{error, {use_dpop_nonce, DpopNonce, Body}};
_ ->
{error, {http_error, StatusCode, Body}}
end.

-spec fetch_content_type(Headers) -> json | jwt | unknown when Headers :: [http_header()].
fetch_content_type(Headers) ->
Expand Down
18 changes: 14 additions & 4 deletions src/oidcc_jwt_util.erl
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
-export([merge_jwks/2]).
-export([refresh_jwks_fun/1]).
-export([sign/3]).
-export([sign/4]).
-export([verify_claims/2]).
-export([verify_signature/3]).

Expand Down Expand Up @@ -172,11 +173,20 @@ merge_jwks(Left, Right) ->
%% @private
-spec sign(Jwt :: #jose_jwt{}, Jwk :: jose_jwk:key(), SupportedAlgorithms :: [binary()]) ->
{ok, binary()} | {error, no_supported_alg_or_key}.
sign(_Jwt, _Jwk, []) ->
sign(Jwt, Jwk, SupportedAlgorithms) ->
sign(Jwt, Jwk, SupportedAlgorithms, #{}).

%% @private
-spec sign(
Jwt :: #jose_jwt{}, Jwk :: jose_jwk:key(), SupportedAlgorithms :: [binary()], JwsFields :: map()
) ->
{ok, binary()} | {error, no_supported_alg_or_key}.
sign(_Jwt, _Jwk, [], _JwsFields) ->
{error, no_supported_alg_or_key};
sign(Jwt, Jwk, [Algorithm | RestAlgorithms]) ->
sign(Jwt, Jwk, [Algorithm | RestAlgorithms], JwsFields0) ->
maybe
#jose_jws{fields = JwsFields} = Jws0 ?= jose_jws:from_map(#{<<"alg">> => Algorithm}),
#jose_jws{fields = JwsFields} =
Jws0 ?= jose_jws:from_map(JwsFields0#{<<"alg">> => Algorithm}),
SigningCallback = fun
(#jose_jwk{fields = #{<<"use">> := <<"sig">>} = Fields} = Key) ->
%% add the kid field to the JWS signature if present
Expand Down Expand Up @@ -205,7 +215,7 @@ sign(Jwt, Jwk, [Algorithm | RestAlgorithms]) ->
{ok, Token} ?= evaluate_for_all_keys(Jwk, SigningCallback),
{ok, Token}
else
_ -> sign(Jwt, Jwk, RestAlgorithms)
_ -> sign(Jwt, Jwk, RestAlgorithms, JwsFields0)
end.

%% @private
Expand Down
Loading

0 comments on commit d28a6dc

Please sign in to comment.