diff --git a/README.md b/README.md
index 559af38..d4eb447 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)
+* [Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449)
## Setup
diff --git a/include/oidcc_client_registration.hrl b/include/oidcc_client_registration.hrl
index db96c94..2f3f5ec 100644
--- a/include/oidcc_client_registration.hrl
+++ b/include/oidcc_client_registration.hrl
@@ -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()}
}).
diff --git a/lib/oidcc/client_registration.ex b/lib/oidcc/client_registration.ex
index 3b413e8..58dcd68 100644
--- a/lib/oidcc/client_registration.ex
+++ b/lib/oidcc/client_registration.ex
@@ -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()}
}
diff --git a/lib/oidcc/token.ex b/lib/oidcc/token.ex
index a9dcbf3..8640837 100644
--- a/lib/oidcc/token.ex
+++ b/lib/oidcc/token.ex
@@ -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()}
diff --git a/src/oidcc_auth_util.erl b/src/oidcc_auth_util.erl
index 5dbabd1..264fd66 100644
--- a/src/oidcc_auth_util.erl
+++ b/src/oidcc_auth_util.erl
@@ -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(
@@ -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}).
diff --git a/src/oidcc_authorization.erl b/src/oidcc_authorization.erl
index 0ebac26..747cabd 100644
--- a/src/oidcc_authorization.erl
+++ b/src/oidcc_authorization.erl
@@ -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()
@@ -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().
diff --git a/src/oidcc_client_registration.erl b/src/oidcc_client_registration.erl
index a3c9b12..2c3d1f5 100644
--- a/src/oidcc_client_registration.erl
+++ b/src/oidcc_client_registration.erl
@@ -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()}
}.
diff --git a/src/oidcc_http_util.erl b/src/oidcc_http_util.erl
index 1ee2493..55d44b9 100644
--- a/src/oidcc_http_util.erl
+++ b/src/oidcc_http_util.erl
@@ -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().
@@ -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) ->
diff --git a/src/oidcc_jwt_util.erl b/src/oidcc_jwt_util.erl
index 2713587..5c53bcd 100644
--- a/src/oidcc_jwt_util.erl
+++ b/src/oidcc_jwt_util.erl
@@ -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]).
@@ -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
@@ -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
diff --git a/src/oidcc_token.erl b/src/oidcc_token.erl
index 906b616..07259e5 100644
--- a/src/oidcc_token.erl
+++ b/src/oidcc_token.erl
@@ -32,6 +32,8 @@
-export([refresh/3]).
-export([retrieve/3]).
-export([validate_id_token/3]).
+-export([authorization_headers/4]).
+-export([authorization_headers/5]).
-export_type([access/0]).
-export_type([client_credentials_opts/0]).
@@ -103,7 +105,8 @@
redirect_uri := uri_string:uri_string(),
request_opts => oidcc_http_util:request_opts(),
url_extension => oidcc_http_util:query_params(),
- body_extension => oidcc_http_util:query_params()
+ body_extension => oidcc_http_util:query_params(),
+ dpop_nonce => binary()
}.
%% Options for retrieving a token
%%
@@ -120,6 +123,8 @@
%%
`refresh_jwks' - How to handle tokens with an unknown `kid'.
%% See {@link oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun()}
%% `redirect_uri' - Redirect uri given to {@link oidcc_authorization:create_redirect_url/2}
+%% `dpop_nonce' - if using DPoP, the `nonce' value to use in the
+%% proof claim
%%
-type refresh_opts_no_sub() ::
@@ -733,7 +738,7 @@ verify_access_token_map_hash(#oidcc_token{}) ->
%% @doc Validate ID Token
%%
%% Usually the id token is validated using {@link retrieve/3}.
-%% If you gget the token passed from somewhere else, this function can validate it.
+%% If you get the token passed from somewhere else, this function can validate it.
%%
%% Examples
%%
@@ -815,15 +820,65 @@ validate_id_token(IdToken, ClientContext, Nonce) ->
end
end.
+%% @doc Authorization headers
+%%
+%% Generate a map of authorization headers to use when using the given
+%% access token to access an API endpoint.
+%%
+%% Examples
+%%
+%% ```
+%% {ok, ClientContext} =
+%% oidcc_client_context:from_configuration_worker(provider_name,
+%% <<"client_id">>,
+%% <<"client_secret">>),
+%%
+%% %% Get Access Token record from somewhere
+%%
+%% Headers =
+%% oidcc:authorization_headers(AccessTokenRecord, :get, Url, ClientContext).
+%% '''
+%% @end
+%% @since 3.2.0
+-spec authorization_headers(AccessTokenRecord, Method, Endpoint, ClientContext) -> HeaderMap when
+ AccessTokenRecord :: access(),
+ Method :: post | get,
+ Endpoint :: uri_string:uri_string(),
+ ClientContext :: oidcc_client_context:t(),
+ HeaderMap :: #{binary() => binary()}.
+-spec authorization_headers(AccessTokenRecord, Method, Endpoint, ClientContext, Opts) ->
+ HeaderMap
+when
+ AccessTokenRecord :: access(),
+ Method :: post | get,
+ Endpoint :: uri_string:uri_string(),
+ ClientContext :: oidcc_client_context:t(),
+ Opts :: #{dpop_nonce => binary()},
+ HeaderMap :: #{binary() => binary()}.
+authorization_headers(AccessTokenRecord, Method, Endpoint, ClientContext) ->
+ authorization_headers(AccessTokenRecord, Method, Endpoint, ClientContext, #{}).
+
+authorization_headers(
+ #oidcc_token_access{} = AccessTokenRecord,
+ Method,
+ Endpoint,
+ #oidcc_client_context{} = ClientContext,
+ Opts
+) ->
+ #oidcc_token_access{token = AccessToken, type = AccessTokenType} = AccessTokenRecord,
+ Header = oidcc_auth_util:add_authorization_header(
+ AccessToken, AccessTokenType, Method, Endpoint, Opts, ClientContext
+ ),
+ maps:from_list([{list_to_binary(Key), list_to_binary([Value])} || {Key, Value} <- Header]).
+
-spec verify_aud_claim(Claims, ClientId) -> ok | {error, error()} when
Claims :: oidcc_jwt_util:claims(), ClientId :: binary().
-verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId) when is_list(Audience) ->
- case lists:member(ClientId, Audience) of
- true -> ok;
- false -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}}
+verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId) ->
+ case Audience of
+ ClientId -> ok;
+ [ClientId] -> ok;
+ _ -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}}
end;
-verify_aud_claim(#{<<"aud">> := ClientId}, ClientId) ->
- ok;
verify_aud_claim(Claims, ClientId) ->
{error, {missing_claim, {<<"aud">>, ClientId}, Claims}}.
@@ -832,7 +887,7 @@ verify_aud_claim(Claims, ClientId) ->
verify_azp_claim(#{<<"azp">> := ClientId}, ClientId) ->
ok;
verify_azp_claim(#{<<"azp">> := _Azp} = Claims, ClientId) ->
- {missing_claim, {<<"azp">>, ClientId}, Claims};
+ {error, {missing_claim, {<<"azp">>, ClientId}, Claims}};
verify_azp_claim(_Claims, _ClientId) ->
ok.
@@ -846,7 +901,9 @@ verify_exp_claim(#{<<"exp">> := Expiry}) ->
case erlang:system_time(second) > Expiry + MaxClockSkew of
true -> {error, token_expired};
false -> ok
- end.
+ end;
+verify_exp_claim(Claims) ->
+ {error, {missing_claim, <<"exp">>, Claims}}.
-spec verify_nbf_claim(Claims) -> ok | {error, error()} when Claims :: oidcc_jwt_util:claims().
verify_nbf_claim(#{<<"nbf">> := Expiry}) ->
@@ -914,17 +971,41 @@ retrieve_a_token(QsBodyIn, PkceVerifier, ClientContext, Opts, TelemetryOpts, Aut
false -> [<<"none">>]
end,
+ DpopOpts =
+ case Opts of
+ #{dpop_nonce := DpopNonce} ->
+ #{nonce => DpopNonce};
+ _ ->
+ #{}
+ end,
maybe
- {ok, {Body, Header}} ?=
+ {ok, {Body, Header1}} ?=
oidcc_auth_util:add_client_authentication(
QsBody, Header0, SupportedAuthMethods, SigningAlgs, Opts, ClientContext
),
+ Header = oidcc_auth_util:add_dpop_proof_header(
+ Header1, post, Endpoint, DpopOpts, ClientContext
+ ),
Request =
{Endpoint, Header, "application/x-www-form-urlencoded", uri_string:compose_query(Body)},
RequestOpts = maps:get(request_opts, Opts, #{}),
{ok, {{json, TokenResponse}, _Headers}} ?=
oidcc_http_util:request(post, Request, TelemetryOpts, RequestOpts),
{ok, TokenResponse}
+ else
+ {error, {use_dpop_nonce, NewDpopNonce, _}} when DpopOpts =:= #{} ->
+ %% only retry automatically if we didn't use a nonce the first time
+ %% (to avoid infinite loops)
+ retrieve_a_token(
+ QsBodyIn,
+ PkceVerifier,
+ ClientContext,
+ Opts#{dpop_nonce => NewDpopNonce},
+ TelemetryOpts,
+ AuthenticateClient
+ );
+ {error, Reason} ->
+ {error, Reason}
end.
-spec add_pkce_verifier(QueryList, PkceVerifier) -> oidcc_http_util:query_params() when
diff --git a/src/oidcc_token_introspection.erl b/src/oidcc_token_introspection.erl
index a28336e..2be4985 100644
--- a/src/oidcc_token_introspection.erl
+++ b/src/oidcc_token_introspection.erl
@@ -45,7 +45,8 @@
-type opts() :: #{
preferred_auth_methods => [oidcc_auth_util:auth_method(), ...],
- request_opts => oidcc_http_util:request_opts()
+ request_opts => oidcc_http_util:request_opts(),
+ dpop_nonce => binary()
}.
-type error() :: client_id_mismatch | introspection_not_supported | oidcc_http_util:error().
@@ -131,18 +132,40 @@ introspect(AccessToken, ClientContext, Opts) ->
topic => [oidcc, introspect_token],
extra_meta => #{issuer => Issuer, client_id => ClientId}
},
-
+ DpopOpts =
+ case Opts of
+ #{dpop_nonce := DpopNonce} ->
+ #{nonce => DpopNonce};
+ _ ->
+ #{}
+ end,
maybe
- {ok, {Body, Header}} ?=
+ {ok, {Body, Header1}} ?=
oidcc_auth_util:add_client_authentication(
Body0, Header0, SupportedAuthMethods, AllowAlgorithms, Opts, ClientContext
),
+ Header = oidcc_auth_util:add_dpop_proof_header(
+ Header1, post, Endpoint, DpopOpts, ClientContext
+ ),
Request =
{Endpoint, Header, "application/x-www-form-urlencoded",
uri_string:compose_query(Body)},
{ok, {{json, Token}, _Headers}} ?=
oidcc_http_util:request(post, Request, TelemetryOpts, RequestOpts),
extract_response(Token, ClientContext)
+ else
+ {error, {use_dpop_nonce, NewDpopNonce, _}} when
+ DpopOpts =:= #{}
+ ->
+ %% only retry automatically if we didn't use a nonce the first time
+ %% (to avoid infinite loops)
+ introspect(
+ AccessToken,
+ ClientContext,
+ Opts#{dpop_nonce => NewDpopNonce}
+ );
+ {error, Reason} ->
+ {error, Reason}
end
end.
diff --git a/src/oidcc_userinfo.erl b/src/oidcc_userinfo.erl
index 0269685..bd155e7 100644
--- a/src/oidcc_userinfo.erl
+++ b/src/oidcc_userinfo.erl
@@ -27,13 +27,17 @@
-export_type([retrieve_opts_no_sub/0]).
-type retrieve_opts_no_sub() ::
- #{refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun()}.
+ #{
+ refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
+ dpop_nonce => binary()
+ }.
%% See {@link retrieve_opts()}
-type retrieve_opts() ::
#{
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
- expected_subject := binary() | any
+ expected_subject => binary() | any,
+ dpop_nonce => binary()
}.
%% Configure userinfo request
%%
@@ -46,10 +50,13 @@
%% See {@link oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun()}
%% `expected_subject' - expected subject for the userinfo
%% (`sub' from id token)
+%% `dpop_nonce' - if using DPoP, the `nonce' value to use in the
+%% proof claim
%%
-type error() ::
{distributed_claim_not_found, {ClaimSource :: binary(), ClaimName :: binary()}}
+ | no_access_token
| invalid_content_type
| bad_subject
| oidcc_jwt_util:error()
@@ -103,19 +110,23 @@
ClientContext :: oidcc_client_context:t(),
Opts :: retrieve_opts_no_sub();
(Token, ClientContext, Opts) -> {ok, oidcc_jwt_util:claims()} | {error, error()} when
- Token :: binary(),
+ Token :: oidcc_token:access() | binary(),
ClientContext :: oidcc_client_context:t(),
Opts :: retrieve_opts().
retrieve(#oidcc_token{} = Token, ClientContext, Opts) ->
#oidcc_token{access = AccessTokenRecord, id = IdTokenRecord} = Token,
- #oidcc_token_access{token = AccessToken} = AccessTokenRecord,
#oidcc_token_id{claims = #{<<"sub">> := ExpectedSubject}} = IdTokenRecord,
- retrieve(
- AccessToken,
- ClientContext,
- maps:put(expected_subject, ExpectedSubject, Opts)
- );
-retrieve(AccessToken, ClientContext, Opts) when is_binary(AccessToken) ->
+ case AccessTokenRecord of
+ #oidcc_token_access{} ->
+ retrieve(
+ AccessTokenRecord,
+ ClientContext,
+ maps:put(expected_subject, ExpectedSubject, Opts)
+ );
+ none ->
+ {error, no_access_token}
+ end;
+retrieve(#oidcc_token_access{} = AccessTokenRecord, #oidcc_client_context{} = ClientContext, Opts) ->
#oidcc_client_context{
provider_configuration = Configuration,
client_id = ClientId
@@ -124,8 +135,16 @@ retrieve(AccessToken, ClientContext, Opts) when is_binary(AccessToken) ->
userinfo_endpoint = Endpoint,
issuer = Issuer
} = Configuration,
+ #oidcc_token_access{token = AccessToken, type = AccessTokenType} = AccessTokenRecord,
- Header = [oidcc_http_util:bearer_auth_header(AccessToken)],
+ %% Dialyzer gets confused about the type of Opts here (thinking that it
+ %% loses the expected_subject key), so we perform a no-op map operation to
+ %% separate the two.
+ %%
+ AuthorizationOpts = Opts#{},
+ Header = oidcc_auth_util:add_authorization_header(
+ AccessToken, AccessTokenType, get, Endpoint, AuthorizationOpts, ClientContext
+ ),
Request = {Endpoint, Header},
RequestOpts = maps:get(request_opts, Opts, #{}),
@@ -134,23 +153,32 @@ retrieve(AccessToken, ClientContext, Opts) when is_binary(AccessToken) ->
extra_meta => #{issuer => Issuer, client_id => ClientId}
},
+ HasDpopNonce = maps:is_key(dpop_nonce, AuthorizationOpts),
+
maybe
{ok, {UserinfoResponse, _Headers}} ?=
oidcc_http_util:request(get, Request, TelemetryOpts, RequestOpts),
{ok, Claims} ?= validate_userinfo_body(UserinfoResponse, ClientContext, Opts),
lookup_distributed_claims(Claims, ClientContext, Opts)
- end.
+ else
+ {error, {use_dpop_nonce, DpopNonce, _}} when
+ HasDpopNonce =:= false
+ ->
+ %% retry once if we didn't provide a nonce the first time
+ retrieve(AccessTokenRecord, ClientContext, Opts#{dpop_nonce => DpopNonce});
+ {error, Reason} ->
+ {error, Reason}
+ end;
+retrieve(AccessToken, #oidcc_client_context{} = ClientContext, Opts) when is_binary(AccessToken) ->
+ AccessTokenRecord = #oidcc_token_access{token = AccessToken},
+ retrieve(AccessTokenRecord, ClientContext, Opts).
-spec validate_userinfo_body(Body, ClientContext, Opts) ->
{ok, Claims} | {error, error()}
when
Body :: {json, map()} | {jwt, binary()},
ClientContext :: oidcc_client_context:t(),
- Opts ::
- #{
- refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
- expected_subject := binary()
- },
+ Opts :: retrieve_opts(),
Claims :: oidcc_jwt_util:claims().
validate_userinfo_body({json, Userinfo}, _ClientContext, Opts) ->
ExpectedSubject = maps:get(expected_subject, Opts),
@@ -188,7 +216,7 @@ when
Opts ::
#{
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
- expected_subject := binary(),
+ expected_subject => binary(),
expected_claims => [{binary(), term()}]
},
Claims :: oidcc_jwt_util:claims().
diff --git a/test/oidcc_authorization_test.erl b/test/oidcc_authorization_test.erl
index 2227f9c..24dcfb6 100644
--- a/test/oidcc_authorization_test.erl
+++ b/test/oidcc_authorization_test.erl
@@ -1006,3 +1006,38 @@ create_redirect_url_with_par_client_secret_jwt_request_object_test() ->
),
ok.
+
+create_redirect_url_private_key_jwt_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, ValidConfigString} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, Configuration0} = oidcc_provider_configuration:decode_configuration(
+ jose:decode(ValidConfigString)
+ ),
+ Configuration = Configuration0#oidcc_provider_configuration{
+ token_endpoint_auth_methods_supported = [<<"private_key_jwt">>],
+ dpop_signing_alg_values_supported = [<<"RS256">>]
+ },
+
+ 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)),
+
+ ok.
diff --git a/test/oidcc_token_test.erl b/test/oidcc_token_test.erl
index 3ac1015..9135b48 100644
--- a/test/oidcc_token_test.erl
+++ b/test/oidcc_token_test.erl
@@ -893,6 +893,460 @@ auth_method_private_key_jwt_test() ->
ok.
+auth_method_private_key_jwt_with_dpop_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, _} = application:ensure_all_started(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, Configuration0} = oidcc_provider_configuration:decode_configuration(
+ jose:decode(ConfigurationBinary)
+ ),
+
+ #oidcc_provider_configuration{token_endpoint = TokenEndpoint, issuer = Issuer} =
+ Configuration = Configuration0#oidcc_provider_configuration{
+ token_endpoint_auth_methods_supported = [<<"private_key_jwt">>],
+ token_endpoint_auth_signing_alg_values_supported = [<<"RS256">>],
+ dpop_signing_alg_values_supported = [<<"RS256">>]
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+ LocalEndpoint = <<"https://my.server/auth">>,
+ AuthCode = <<"1234567890">>,
+ AccessToken = <<"access_token">>,
+ RefreshToken = <<"refresh_token">>,
+ Claims =
+ #{
+ <<"iss">> => Issuer,
+ <<"sub">> => <<"sub">>,
+ <<"aud">> => ClientId,
+ <<"iat">> => erlang:system_time(second),
+ <<"exp">> => erlang:system_time(second) + 10,
+ <<"at_hash">> => <<"hrOQHuo3oE6FR82RIiX1SA">>
+ },
+
+ Jwk = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+
+ Jwt = jose_jwt:from(Claims),
+ Jws = #{<<"alg">> => <<"RS256">>},
+ {_Jws, Token} =
+ jose_jws:compact(
+ jose_jwt:sign(Jwk, Jws, Jwt)
+ ),
+
+ TokenData =
+ jsx:encode(#{
+ <<"access_token">> => AccessToken,
+ <<"token_type">> => <<"Bearer">>,
+ <<"id_token">> => Token,
+ <<"scope">> => <<"profile openid">>,
+ <<"refresh_token">> => RefreshToken
+ }),
+
+ ClientJwk0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+ ClientJwk = ClientJwk0#jose_jwk{
+ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}
+ },
+
+ ClientContext = oidcc_client_context:from_manual(Configuration, Jwk, ClientId, ClientSecret, #{
+ client_jwks => ClientJwk
+ }),
+
+ ok = meck:new(httpc, [no_link]),
+ HttpFun =
+ fun(
+ post,
+ {ReqTokenEndpoint, Header, "application/x-www-form-urlencoded", Body},
+ _HttpOpts,
+ _Opts
+ ) ->
+ TokenEndpoint = ReqTokenEndpoint,
+ ?assertMatch(none, proplists:lookup("authorization", Header)),
+ BodyMap = maps:from_list(uri_string:dissect_query(Body)),
+
+ ?assertMatch(
+ #{
+ <<"grant_type">> := <<"authorization_code">>,
+ <<"code">> := AuthCode,
+ <<"client_id">> := ClientId,
+ <<"client_assertion_type">> :=
+ <<"urn:ietf:params:oauth:client-assertion-type:jwt-bearer">>,
+ <<"client_assertion">> := _
+ },
+ BodyMap
+ ),
+
+ ClientAssertion = maps:get(<<"client_assertion">>, BodyMap),
+
+ {true, ClientAssertionJwt, ClientAssertionJws} = jose_jwt:verify(
+ ClientJwk, ClientAssertion
+ ),
+
+ ?assertMatch(
+ #jose_jws{alg = {_, 'RS256'}}, ClientAssertionJws
+ ),
+
+ #jose_jws{fields = ClientAssertionJwsFields} = ClientAssertionJws,
+ ?assertMatch(
+ #{
+ <<"kid">> := <<"private_kid">>
+ },
+ ClientAssertionJwsFields
+ ),
+
+ ?assertMatch(
+ #jose_jwt{
+ fields = #{
+ <<"aud">> := TokenEndpoint,
+ <<"exp">> := _,
+ <<"iat">> := _,
+ <<"iss">> := ClientId,
+ <<"jti">> := _,
+ <<"nbf">> := _,
+ <<"sub">> := ClientId
+ }
+ },
+ ClientAssertionJwt
+ ),
+
+ {_, DpopProof} = proplists:lookup("dpop", Header),
+
+ {true, DpopJwt, DpopJws} = jose_jwt:verify(
+ ClientJwk, DpopProof
+ ),
+
+ ?assertMatch(
+ #jose_jws{alg = {_, 'RS256'}}, DpopJws
+ ),
+
+ #jose_jws{fields = DpopJwsFields} = DpopJws,
+ ?assertMatch(
+ #{
+ <<"kid">> := <<"private_kid">>,
+ <<"typ">> := <<"dpop+jwt">>,
+ <<"jwk">> := _
+ },
+ DpopJwsFields
+ ),
+
+ #{<<"jwk">> := DpopPublicKeyMap} = DpopJwsFields,
+ ?assertEqual(
+ DpopPublicKeyMap,
+ element(2, jose_jwk:to_public_map(ClientJwk))
+ ),
+
+ ?assertMatch(
+ #jose_jwt{
+ fields = #{
+ <<"exp">> := _,
+ <<"iat">> := _,
+ <<"jti">> := _,
+ <<"htm">> := <<"POST">>,
+ <<"htu">> := TokenEndpoint
+ }
+ },
+ DpopJwt
+ ),
+
+ {ok, {{"HTTP/1.1", 200, "OK"}, [{"content-type", "application/json"}], TokenData}}
+ end,
+ ok = meck:expect(httpc, request, HttpFun),
+
+ ?assertMatch(
+ {ok, #oidcc_token{
+ id = #oidcc_token_id{token = Token, claims = Claims},
+ access = #oidcc_token_access{token = AccessToken},
+ refresh = #oidcc_token_refresh{token = RefreshToken},
+ scope = [<<"profile">>, <<"openid">>]
+ }},
+ oidcc_token:retrieve(
+ AuthCode,
+ ClientContext,
+ #{redirect_uri => LocalEndpoint}
+ )
+ ),
+
+ true = meck:validate(httpc),
+
+ meck:unload(httpc),
+
+ ok.
+
+auth_method_private_key_jwt_with_dpop_and_nonce_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, _} = application:ensure_all_started(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, Configuration0} = oidcc_provider_configuration:decode_configuration(
+ jose:decode(ConfigurationBinary)
+ ),
+
+ #oidcc_provider_configuration{token_endpoint = TokenEndpoint, issuer = Issuer} =
+ Configuration = Configuration0#oidcc_provider_configuration{
+ token_endpoint_auth_methods_supported = [<<"private_key_jwt">>],
+ token_endpoint_auth_signing_alg_values_supported = [<<"RS256">>],
+ dpop_signing_alg_values_supported = [<<"RS256">>]
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+ LocalEndpoint = <<"https://my.server/auth">>,
+ AuthCode = <<"1234567890">>,
+ AccessToken = <<"access_token">>,
+ RefreshToken = <<"refresh_token">>,
+ DpopNonce = <<"dpop_nonce">>,
+ Claims =
+ #{
+ <<"iss">> => Issuer,
+ <<"sub">> => <<"sub">>,
+ <<"aud">> => ClientId,
+ <<"iat">> => erlang:system_time(second),
+ <<"exp">> => erlang:system_time(second) + 10,
+ <<"at_hash">> => <<"hrOQHuo3oE6FR82RIiX1SA">>
+ },
+
+ Jwk = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+
+ Jwt = jose_jwt:from(Claims),
+ Jws = #{<<"alg">> => <<"RS256">>},
+ {_Jws, Token} =
+ jose_jws:compact(
+ jose_jwt:sign(Jwk, Jws, Jwt)
+ ),
+
+ TokenData =
+ jsx:encode(#{
+ <<"access_token">> => AccessToken,
+ <<"token_type">> => <<"Bearer">>,
+ <<"id_token">> => Token,
+ <<"scope">> => <<"profile openid">>,
+ <<"refresh_token">> => RefreshToken
+ }),
+
+ DpopNonceError = jsx:encode(#{
+ <<"error">> => <<"use_dpop_nonce">>,
+ <<"error_description">> =>
+ <<"Authorization server requires nonce in DPoP proof">>
+ }),
+
+ ClientJwk0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+ ClientJwk = ClientJwk0#jose_jwk{
+ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}
+ },
+
+ ClientContext = oidcc_client_context:from_manual(Configuration, Jwk, ClientId, ClientSecret, #{
+ client_jwks => ClientJwk
+ }),
+
+ ok = meck:new(httpc, [no_link]),
+ HttpFun =
+ fun(
+ post,
+ {ReqTokenEndpoint, Header, "application/x-www-form-urlencoded", Body},
+ _HttpOpts,
+ _Opts
+ ) ->
+ TokenEndpoint = ReqTokenEndpoint,
+ ?assertMatch(none, proplists:lookup("authorization", Header)),
+ BodyMap = maps:from_list(uri_string:dissect_query(Body)),
+
+ ?assertMatch(
+ #{
+ <<"grant_type">> := <<"authorization_code">>,
+ <<"code">> := AuthCode,
+ <<"client_id">> := ClientId,
+ <<"client_assertion_type">> :=
+ <<"urn:ietf:params:oauth:client-assertion-type:jwt-bearer">>,
+ <<"client_assertion">> := _
+ },
+ BodyMap
+ ),
+
+ ClientAssertion = maps:get(<<"client_assertion">>, BodyMap),
+
+ {true, ClientAssertionJwt, ClientAssertionJws} = jose_jwt:verify(
+ ClientJwk, ClientAssertion
+ ),
+
+ ?assertMatch(
+ #jose_jws{alg = {_, 'RS256'}}, ClientAssertionJws
+ ),
+
+ #jose_jws{fields = ClientAssertionJwsFields} = ClientAssertionJws,
+ ?assertMatch(
+ #{
+ <<"kid">> := <<"private_kid">>
+ },
+ ClientAssertionJwsFields
+ ),
+
+ ?assertMatch(
+ #jose_jwt{
+ fields = #{
+ <<"aud">> := TokenEndpoint,
+ <<"exp">> := _,
+ <<"iat">> := _,
+ <<"iss">> := ClientId,
+ <<"jti">> := _,
+ <<"nbf">> := _,
+ <<"sub">> := ClientId
+ }
+ },
+ ClientAssertionJwt
+ ),
+
+ {_, DpopProof} = proplists:lookup("dpop", Header),
+
+ {true, DpopJwt, DpopJws} = jose_jwt:verify(
+ ClientJwk, DpopProof
+ ),
+
+ ?assertMatch(
+ #jose_jws{alg = {_, 'RS256'}}, DpopJws
+ ),
+
+ #jose_jws{fields = DpopJwsFields} = DpopJws,
+ ?assertMatch(
+ #{
+ <<"kid">> := <<"private_kid">>,
+ <<"typ">> := <<"dpop+jwt">>,
+ <<"jwk">> := _
+ },
+ DpopJwsFields
+ ),
+
+ #{<<"jwk">> := DpopPublicKeyMap} = DpopJwsFields,
+ ?assertEqual(
+ DpopPublicKeyMap,
+ element(2, jose_jwk:to_public_map(ClientJwk))
+ ),
+
+ ?assertMatch(
+ #jose_jwt{
+ fields = #{
+ <<"exp">> := _,
+ <<"iat">> := _,
+ <<"jti">> := _,
+ <<"htm">> := <<"POST">>,
+ <<"htu">> := TokenEndpoint
+ }
+ },
+ DpopJwt
+ ),
+
+ case DpopJwt of
+ #jose_jwt{
+ fields = #{
+ <<"nonce">> := DpopNonce
+ }
+ } ->
+ {ok, {
+ {"HTTP/1.1", 200, "OK"}, [{"content-type", "application/json"}], TokenData
+ }};
+ _ ->
+ {ok, {
+ {"HTTP/1.1", 400, "OK"},
+ [{"content-type", "application/json"}, {"dpop-nonce", DpopNonce}],
+ DpopNonceError
+ }}
+ end
+ end,
+ ok = meck:expect(httpc, request, HttpFun),
+
+ ?assertMatch(
+ {ok, #oidcc_token{
+ id = #oidcc_token_id{token = Token, claims = Claims},
+ access = #oidcc_token_access{token = AccessToken},
+ refresh = #oidcc_token_refresh{token = RefreshToken},
+ scope = [<<"profile">>, <<"openid">>]
+ }},
+ oidcc_token:retrieve(
+ AuthCode,
+ ClientContext,
+ #{redirect_uri => LocalEndpoint}
+ )
+ ),
+
+ true = meck:validate(httpc),
+
+ meck:unload(httpc),
+
+ ok.
+
+auth_method_private_key_jwt_with_invalid_dpop_nonce_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, _} = application:ensure_all_started(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, Configuration0} = oidcc_provider_configuration:decode_configuration(
+ jose:decode(ConfigurationBinary)
+ ),
+
+ Configuration = Configuration0#oidcc_provider_configuration{
+ token_endpoint_auth_methods_supported = [<<"private_key_jwt">>],
+ token_endpoint_auth_signing_alg_values_supported = [<<"RS256">>],
+ dpop_signing_alg_values_supported = [<<"RS256">>]
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+ LocalEndpoint = <<"https://my.server/auth">>,
+ AuthCode = <<"1234567890">>,
+ DpopNonce = <<"dpop_nonce">>,
+ Jwk = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+
+ DpopNonceError = jsx:encode(#{
+ <<"error">> => <<"use_dpop_nonce">>,
+ <<"error_description">> =>
+ <<"Authorization server requires nonce in DPoP proof">>
+ }),
+
+ ClientJwk0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+ ClientJwk = ClientJwk0#jose_jwk{
+ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}
+ },
+
+ ClientContext = oidcc_client_context:from_manual(Configuration, Jwk, ClientId, ClientSecret, #{
+ client_jwks => ClientJwk
+ }),
+
+ ok = meck:new(httpc, [no_link]),
+ HttpFun =
+ fun(
+ post,
+ {_Endpoint, _Header, "application/x-www-form-urlencoded", _Body},
+ _HttpOpts,
+ _Opts
+ ) ->
+ {ok, {
+ {"HTTP/1.1", 400, "OK"},
+ [{"content-type", "application/json"}, {"dpop-nonce", DpopNonce}],
+ DpopNonceError
+ }}
+ end,
+ ok = meck:expect(httpc, request, HttpFun),
+
+ ?assertMatch(
+ {error, _},
+ oidcc_token:retrieve(
+ AuthCode,
+ ClientContext,
+ #{
+ redirect_uri => LocalEndpoint,
+ dpop_nonce => <<"invalid_nonce">>
+ }
+ )
+ ),
+
+ true = meck:validate(httpc),
+
+ meck:unload(httpc),
+
+ ok.
+
auth_method_client_secret_jwt_no_alg_test() ->
PrivDir = code:priv_dir(oidcc),
@@ -1030,3 +1484,120 @@ preferred_auth_methods_test() ->
meck:unload(httpc),
ok.
+
+authorization_headers_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, Configuration0} = oidcc_provider_configuration:decode_configuration(
+ jose:decode(ConfigurationBinary)
+ ),
+
+ SigningAlg = [<<"RS256">>],
+
+ Configuration = Configuration0#oidcc_provider_configuration{
+ dpop_signing_alg_values_supported = SigningAlg
+ },
+
+ Jwk = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+ ClientJwk0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+ ClientJwk = ClientJwk0#jose_jwk{
+ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}
+ },
+ {_, ClientPublicJwk} = jose_jwk:to_public_map(ClientJwk),
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+ Endpoint = <<"https://my.server/auth">>,
+ AccessToken = <<"access_token">>,
+ AccessTokenHash = base64:encode(crypto:hash(sha256, AccessToken), #{
+ mode => urlsafe, padding => false
+ }),
+
+ ClientContext = oidcc_client_context:from_manual(Configuration, Jwk, ClientId, ClientSecret, #{
+ client_jwks => ClientJwk
+ }),
+
+ AccessTokenRecord = #oidcc_token_access{token = AccessToken, type = <<"DPoP">>},
+
+ HeaderMap = oidcc_token:authorization_headers(AccessTokenRecord, get, Endpoint, ClientContext),
+ HeaderMapWithNonce = oidcc_token:authorization_headers(
+ AccessTokenRecord, post, Endpoint, ClientContext, #{dpop_nonce => <<"dpop_nonce">>}
+ ),
+
+ ?assertMatch(
+ #{
+ <<"authorization">> := <<"DPoP access_token">>,
+ <<"dpop">> := _
+ },
+ HeaderMap
+ ),
+
+ ?assertMatch(
+ #{
+ <<"authorization">> := <<"DPoP access_token">>,
+ <<"dpop">> := _
+ },
+ HeaderMapWithNonce
+ ),
+
+ #{<<"dpop">> := DpopProof} = HeaderMap,
+ #{<<"dpop">> := DpopProofWithNonce} = HeaderMapWithNonce,
+
+ ?assertMatch(
+ {ok, _},
+ oidcc_jwt_util:verify_signature(DpopProof, SigningAlg, ClientJwk)
+ ),
+ ?assertMatch(
+ {ok, _},
+ oidcc_jwt_util:verify_signature(DpopProofWithNonce, SigningAlg, ClientJwk)
+ ),
+
+ {ok, {DpopJwt, DpopJws}} = oidcc_jwt_util:verify_signature(DpopProof, SigningAlg, ClientJwk),
+ {ok, {DpopJwtWithNonce, DpopJwsWithNonce}} = oidcc_jwt_util:verify_signature(
+ DpopProofWithNonce, SigningAlg, ClientJwk
+ ),
+
+ ?assertMatch(
+ #jose_jws{
+ fields = #{
+ <<"typ">> := <<"dpop+jwt">>,
+ <<"kid">> := <<"private_kid">>,
+ <<"jwk">> := ClientPublicJwk
+ }
+ },
+ DpopJws
+ ),
+ ?assertEqual(
+ DpopJws,
+ DpopJwsWithNonce
+ ),
+
+ ?assertMatch(
+ #jose_jwt{
+ fields = #{
+ <<"jti">> := _,
+ <<"htm">> := <<"GET">>,
+ <<"htu">> := Endpoint,
+ <<"iat">> := _,
+ <<"exp">> := _,
+ <<"ath">> := AccessTokenHash
+ }
+ },
+ DpopJwt
+ ),
+ ?assertMatch(
+ #jose_jwt{
+ fields = #{
+ <<"jti">> := _,
+ <<"htm">> := <<"POST">>,
+ <<"htu">> := Endpoint,
+ <<"iat">> := _,
+ <<"exp">> := _,
+ <<"ath">> := AccessTokenHash,
+ <<"nonce">> := <<"dpop_nonce">>
+ }
+ },
+ DpopJwtWithNonce
+ ),
+ ok.
diff --git a/test/oidcc_userinfo_test.erl b/test/oidcc_userinfo_test.erl
index b29a9c9..e71b67f 100644
--- a/test/oidcc_userinfo_test.erl
+++ b/test/oidcc_userinfo_test.erl
@@ -2,6 +2,8 @@
-include_lib("eunit/include/eunit.hrl").
-include_lib("jose/include/jose_jwk.hrl").
+-include_lib("jose/include/jose_jws.hrl").
+-include_lib("jose/include/jose_jwt.hrl").
-include_lib("oidcc/include/oidcc_provider_configuration.hrl").
-include_lib("oidcc/include/oidcc_token.hrl").
@@ -34,7 +36,7 @@ json_test() ->
AccessToken = <<"opensesame">>,
GoodToken =
#oidcc_token{
- access = #oidcc_token_access{token = AccessToken},
+ access = AccessTokenRecord = #oidcc_token_access{token = AccessToken},
id =
#oidcc_token_id{
token = "id_token",
@@ -55,6 +57,12 @@ json_test() ->
{ok, #{<<"name">> := <<"joe">>}},
oidcc_userinfo:retrieve(GoodToken, ClientContext, #{})
),
+ ?assertMatch(
+ {ok, #{<<"name">> := <<"joe">>}},
+ oidcc_userinfo:retrieve(AccessTokenRecord, ClientContext, #{
+ expected_subject => GoodSub
+ })
+ ),
?assertMatch(
{ok, #{<<"name">> := <<"joe">>}},
oidcc_userinfo:retrieve(
@@ -533,3 +541,358 @@ distributed_claims_invalid_source_mapping_test() ->
meck:unload(oidcc_http_util),
ok.
+
+dpop_proof_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, #oidcc_provider_configuration{userinfo_endpoint = UserInfoEndpoint} = Configuration0} =
+ oidcc_provider_configuration:decode_configuration(jose:decode(ConfigurationBinary)),
+
+ Configuration = Configuration0#oidcc_provider_configuration{
+ dpop_signing_alg_values_supported = [<<"RS256">>]
+ },
+ Jwks = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+
+ ClientJwk = Jwks#jose_jwk{
+ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+
+ ClientContext = oidcc_client_context:from_manual(
+ Configuration, Jwks, ClientId, ClientSecret, #{client_jwks => ClientJwk}
+ ),
+
+ HttpBody = <<"{\"name\":\"joe\", \"sub\":\"123456\"}">>,
+ Sub = <<"123456">>,
+ AccessToken = <<"opensesame">>,
+ AccessTokenHash = base64:encode(
+ crypto:hash(sha256, AccessToken),
+ #{mode => urlsafe, padding => false}
+ ),
+
+ HttpFun =
+ fun(get, {Url, Header}, _HttpOpts, _Opts) ->
+ Url = UserInfoEndpoint,
+ {_, Authorization} =
+ proplists:lookup("authorization", Header),
+ ?assertEqual(
+ list_to_binary(Authorization),
+ list_to_binary([<<"DPoP ">>, AccessToken])
+ ),
+ {_, DpopProof} = proplists:lookup("dpop", Header),
+ {true, DpopJwt, DpopJws} = jose_jwt:verify(
+ ClientJwk, DpopProof
+ ),
+
+ ?assertMatch(
+ #jose_jws{alg = {_, 'RS256'}}, DpopJws
+ ),
+
+ #jose_jws{fields = DpopJwsFields} = DpopJws,
+ ?assertMatch(
+ #{
+ <<"kid">> := <<"private_kid">>,
+ <<"typ">> := <<"dpop+jwt">>,
+ <<"jwk">> := _
+ },
+ DpopJwsFields
+ ),
+
+ #{<<"jwk">> := DpopPublicKeyMap} = DpopJwsFields,
+ ?assertEqual(
+ DpopPublicKeyMap,
+ element(2, jose_jwk:to_public_map(ClientJwk))
+ ),
+
+ ?assertMatch(
+ #jose_jwt{
+ fields = #{
+ <<"exp">> := _,
+ <<"iat">> := _,
+ <<"jti">> := _,
+ <<"htm">> := <<"GET">>,
+ <<"htu">> := UserInfoEndpoint,
+ <<"ath">> := AccessTokenHash
+ }
+ },
+ DpopJwt
+ ),
+
+ {ok, {{"HTTP/1.1", 200, "OK"}, [{"content-type", "application/json"}], HttpBody}}
+ end,
+ ok = meck:new(httpc),
+ ok = meck:expect(httpc, request, HttpFun),
+
+ Token =
+ #oidcc_token{
+ access =
+ #oidcc_token_access{token = AccessToken, type = <<"DPoP">>},
+ 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.
+
+dpop_proof_case_insensitive_token_type_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, #oidcc_provider_configuration{userinfo_endpoint = UserInfoEndpoint} = Configuration0} =
+ oidcc_provider_configuration:decode_configuration(jose:decode(ConfigurationBinary)),
+
+ Configuration = Configuration0#oidcc_provider_configuration{
+ dpop_signing_alg_values_supported = [<<"RS256">>]
+ },
+ Jwks = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+
+ ClientJwk = Jwks#jose_jwk{
+ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+
+ ClientContext = oidcc_client_context:from_manual(
+ Configuration, Jwks, ClientId, ClientSecret, #{client_jwks => ClientJwk}
+ ),
+
+ HttpBody = <<"{\"name\":\"joe\", \"sub\":\"123456\"}">>,
+ Sub = <<"123456">>,
+ AccessToken = <<"opensesame">>,
+
+ HttpFun =
+ fun(get, {Url, Header}, _HttpOpts, _Opts) ->
+ Url = UserInfoEndpoint,
+ {_, Authorization} =
+ proplists:lookup("authorization", Header),
+ ?assertEqual(
+ list_to_binary(Authorization),
+ list_to_binary([<<"dpOp ">>, AccessToken])
+ ),
+ ?assertMatch({_, _}, proplists:lookup("dpop", Header)),
+
+ {ok, {{"HTTP/1.1", 200, "OK"}, [{"content-type", "application/json"}], HttpBody}}
+ end,
+ ok = meck:new(httpc),
+ ok = meck:expect(httpc, request, HttpFun),
+
+ Token =
+ #oidcc_token{
+ access =
+ #oidcc_token_access{token = AccessToken, type = <<"dpOp">>},
+ 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.
+
+dpop_proof_with_nonce_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, #oidcc_provider_configuration{userinfo_endpoint = UserInfoEndpoint} = Configuration0} =
+ oidcc_provider_configuration:decode_configuration(jose:decode(ConfigurationBinary)),
+
+ Configuration = Configuration0#oidcc_provider_configuration{
+ dpop_signing_alg_values_supported = [<<"RS256">>]
+ },
+ Jwks = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+
+ ClientJwk = Jwks#jose_jwk{
+ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+
+ ClientContext = oidcc_client_context:from_manual(
+ Configuration, Jwks, ClientId, ClientSecret, #{client_jwks => ClientJwk}
+ ),
+
+ HttpBody = <<"{\"name\":\"joe\", \"sub\":\"123456\"}">>,
+ Sub = <<"123456">>,
+ AccessToken = <<"opensesame">>,
+ AccessTokenHash = base64:encode(
+ crypto:hash(sha256, AccessToken),
+ #{mode => urlsafe, padding => false}
+ ),
+ DpopNonce = <<"dpop_nonce">>,
+ DpopNonceError = jsx:encode(#{
+ <<"error">> => <<"use_dpop_nonce">>,
+ <<"error_description">> =>
+ <<"Authorization server requires nonce in DPoP proof">>
+ }),
+
+ HttpFun =
+ fun(get, {Url, Header}, _HttpOpts, _Opts) ->
+ Url = UserInfoEndpoint,
+ {_, Authorization} =
+ proplists:lookup("authorization", Header),
+ ?assertEqual(
+ list_to_binary(Authorization),
+ list_to_binary([<<"DPoP ">>, AccessToken])
+ ),
+ {_, DpopProof} = proplists:lookup("dpop", Header),
+ {true, DpopJwt, DpopJws} = jose_jwt:verify(
+ ClientJwk, DpopProof
+ ),
+
+ ?assertMatch(
+ #jose_jws{alg = {_, 'RS256'}}, DpopJws
+ ),
+
+ #jose_jws{fields = DpopJwsFields} = DpopJws,
+ ?assertMatch(
+ #{
+ <<"kid">> := <<"private_kid">>,
+ <<"typ">> := <<"dpop+jwt">>,
+ <<"jwk">> := _
+ },
+ DpopJwsFields
+ ),
+
+ #{<<"jwk">> := DpopPublicKeyMap} = DpopJwsFields,
+ ?assertEqual(
+ DpopPublicKeyMap,
+ element(2, jose_jwk:to_public_map(ClientJwk))
+ ),
+
+ ?assertMatch(
+ #jose_jwt{
+ fields = #{
+ <<"exp">> := _,
+ <<"iat">> := _,
+ <<"jti">> := _,
+ <<"htm">> := <<"GET">>,
+ <<"htu">> := UserInfoEndpoint,
+ <<"ath">> := AccessTokenHash
+ }
+ },
+ DpopJwt
+ ),
+
+ case DpopJwt of
+ #jose_jwt{fields = #{<<"nonce">> := DpopNonce}} ->
+ {ok, {
+ {"HTTP/1.1", 200, "OK"}, [{"content-type", "application/json"}], HttpBody
+ }};
+ _ ->
+ {ok, {
+ {"HTTP/1.1", 400, "Bad Request"},
+ [{"content-type", "application/json"}, {"dpop-nonce", DpopNonce}],
+ DpopNonceError
+ }}
+ end
+ end,
+ ok = meck:new(httpc),
+ ok = meck:expect(httpc, request, HttpFun),
+
+ Token =
+ #oidcc_token{
+ access =
+ #oidcc_token_access{token = AccessToken, type = <<"DPoP">>},
+ 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.
+
+dpop_proof_with_invalid_nonce_test() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, Configuration0} =
+ oidcc_provider_configuration:decode_configuration(jose:decode(ConfigurationBinary)),
+
+ Configuration = Configuration0#oidcc_provider_configuration{
+ dpop_signing_alg_values_supported = [<<"RS256">>]
+ },
+ Jwks = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+
+ ClientJwk = Jwks#jose_jwk{
+ fields = #{<<"kid">> => <<"private_kid">>, <<"use">> => <<"sig">>}
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+
+ ClientContext = oidcc_client_context:from_manual(
+ Configuration, Jwks, ClientId, ClientSecret, #{client_jwks => ClientJwk}
+ ),
+
+ Sub = <<"123456">>,
+ AccessToken = <<"opensesame">>,
+ DpopNonce = <<"dpop_nonce">>,
+ DpopNonceError = jsx:encode(#{
+ <<"error">> => <<"use_dpop_nonce">>,
+ <<"error_description">> =>
+ <<"Authorization server requires nonce in DPoP proof">>
+ }),
+
+ HttpFun =
+ fun(get, _UrlHeader, _HttpOpts, _Opts) ->
+ {ok, {
+ {"HTTP/1.1", 400, "Bad Request"},
+ [{"content-type", "application/json"}, {"dpop-nonce", DpopNonce}],
+ DpopNonceError
+ }}
+ end,
+ ok = meck:new(httpc),
+ ok = meck:expect(httpc, request, HttpFun),
+
+ Token =
+ #oidcc_token{
+ access =
+ #oidcc_token_access{token = AccessToken, type = <<"DPoP">>},
+ id =
+ #oidcc_token_id{
+ token = "id_token",
+ claims = #{<<"sub">> => Sub}
+ }
+ },
+
+ ?assertMatch(
+ {error, _},
+ oidcc_userinfo:retrieve(Token, ClientContext, #{dpop_nonce => <<"invalid_nonce">>})
+ ),
+
+ true = meck:validate(httpc),
+ meck:unload(httpc),
+
+ ok.