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.