Skip to content

Commit

Permalink
feat: Pushed Authorization Request (PAR) (#313)
Browse files Browse the repository at this point in the history
* fix: return PKCE challenge keys as binaries

* feat: Pushed Authorization Request (PAR)

* fixup! feat: Pushed Authorization Request (PAR)
  • Loading branch information
paulswartz authored Dec 18, 2023
1 parent e567c01 commit 9f5198b
Show file tree
Hide file tree
Showing 8 changed files with 519 additions and 25 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ The refactoring for `v3` and the certification is funded as an
* Authorization (Code Flow)
* [Request Object](https://openid.net/specs/openid-connect-core-1_0.html#RequestObject)
* [PKCE](https://oauth.net/2/pkce/)
* [Pushed Authorization Requests](https://datatracker.ietf.org/doc/html/rfc9126)
* Token
* Authorization: `client_secret_basic`, `client_secret_post`,
`client_secret_jwt`, and `private_key_jwt`
Expand Down
2 changes: 2 additions & 0 deletions include/oidcc_client_registration.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
request_uris = undefined :: [uri_string:uri_string()] | undefined,
%% OpenID Connect RP-Initiated Logout 1.0
post_logout_redirect_uris = undefined :: [uri_string:uri_string()] | undefined,
%% OAuth 2.0 Pushed Authorization Requests
require_pushed_authorization_requests = false :: boolean(),
%% Unknown Fields
extra_fields = #{} :: #{binary() => term()}
}).
Expand Down
1 change: 1 addition & 0 deletions lib/oidcc/client_registration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ defmodule Oidcc.ClientRegistration do
initiate_login_uri: :uri_string.uri_string() | :undefined,
request_uris: [:uri_string.uri_string()] | :undefined,
post_logout_redirect_uris: [:uri_string.uri_string()] | :undefined,
require_pushed_authorization_requests: boolean(),
extra_fields: %{String.t() => term()}
}

Expand Down
30 changes: 22 additions & 8 deletions src/oidcc_auth_util.erl
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ add_client_authentication(
case select_preferred_auth(PreferredAuthMethods, SupportedAuthMethods) of
{ok, AuthMethod} ->
case
add_authentication(QueryList0, Header0, AuthMethod, AllowAlgorithms, ClientContext)
add_authentication(
QueryList0, Header0, AuthMethod, AllowAlgorithms, Opts, ClientContext
)
of
{ok, {QueryList, Header}} ->
{ok, {QueryList, Header}};
Expand All @@ -71,6 +73,7 @@ add_client_authentication(
Header,
AuthMethod,
AllowAlgorithms,
Opts,
ClientContext
) ->
{ok, {oidcc_http_util:query_params(), [oidcc_http_util:http_header()]}}
Expand All @@ -80,12 +83,14 @@ when
Header :: [oidcc_http_util:http_header()],
AuthMethod :: auth_method(),
AllowAlgorithms :: [binary()] | undefined,
Opts :: map(),
ClientContext :: oidcc_client_context:t().
add_authentication(
QsBodyList,
Header,
none,
_AllowArgs,
_Opts,
#oidcc_client_context{client_id = ClientId}
) ->
NewBodyList = [{<<"client_id">>, ClientId} | QsBodyList],
Expand All @@ -95,6 +100,7 @@ add_authentication(
_Header,
_Method,
_AllowAlgs,
_Opts,
#oidcc_client_context{client_secret = unauthenticated}
) ->
{error, auth_method_not_possible};
Expand All @@ -103,6 +109,7 @@ add_authentication(
Header,
client_secret_basic,
_AllowAlgs,
_Opts,
#oidcc_client_context{client_id = ClientId, client_secret = ClientSecret}
) ->
NewHeader = [oidcc_http_util:basic_auth_header(ClientId, ClientSecret) | Header],
Expand All @@ -112,6 +119,7 @@ add_authentication(
Header,
client_secret_post,
_AllowAlgs,
_Opts,
#oidcc_client_context{client_id = ClientId, client_secret = ClientSecret}
) ->
NewBodyList =
Expand All @@ -122,6 +130,7 @@ add_authentication(
Header,
client_secret_jwt,
AllowAlgorithms,
Opts,
ClientContext
) ->
#oidcc_client_context{
Expand All @@ -139,6 +148,7 @@ add_authentication(
{ok, ClientAssertion} ?=
signed_client_assertion(
AllowAlgorithms,
Opts,
ClientContext,
OctJwk
),
Expand All @@ -152,6 +162,7 @@ add_authentication(
Header,
private_key_jwt,
AllowAlgorithms,
Opts,
ClientContext
) ->
#oidcc_client_context{
Expand All @@ -162,7 +173,7 @@ add_authentication(
[_ | _] ?= AllowAlgorithms,
#jose_jwk{} ?= ClientJwks,
{ok, ClientAssertion} ?=
signed_client_assertion(AllowAlgorithms, ClientContext, ClientJwks),
signed_client_assertion(AllowAlgorithms, Opts, ClientContext, ClientJwks),
{ok, add_jwt_bearer_assertion(ClientAssertion, QsBodyList, Header, ClientContext)}
else
_ ->
Expand All @@ -186,23 +197,26 @@ select_preferred_auth(PreferredAuthMethods, AuthMethodsSupported) ->
{error, no_supported_auth_method}
end.

-spec signed_client_assertion(AllowAlgorithms, ClientContext, Jwk) ->
-spec signed_client_assertion(AllowAlgorithms, Opts, ClientContext, Jwk) ->
{ok, binary()} | {error, term()}
when
AllowAlgorithms :: [binary()],
Jwk :: jose_jwk:key(),
Opts :: map(),
ClientContext :: oidcc_client_context:t().
signed_client_assertion(AllowAlgorithms, ClientContext, Jwk) ->
Jwt = jose_jwt:from(token_request_claims(ClientContext)),
signed_client_assertion(AllowAlgorithms, Opts, ClientContext, Jwk) ->
Jwt = jose_jwt:from(token_request_claims(Opts, ClientContext)),

oidcc_jwt_util:sign(Jwt, Jwk, AllowAlgorithms).

-spec token_request_claims(ClientContext) -> oidcc_jwt_util:claims() when
-spec token_request_claims(Opts, ClientContext) -> oidcc_jwt_util:claims() when
Opts :: map(),
ClientContext :: oidcc_client_context:t().
token_request_claims(#oidcc_client_context{
token_request_claims(Opts, #oidcc_client_context{
client_id = ClientId,
provider_configuration = #oidcc_provider_configuration{token_endpoint = TokenEndpoint}
}) ->
Audience = maps:get(audience, Opts, TokenEndpoint),
MaxClockSkew =
case application:get_env(oidcc, max_clock_skew) of
undefined -> 0;
Expand All @@ -212,7 +226,7 @@ token_request_claims(#oidcc_client_context{
#{
<<"iss">> => ClientId,
<<"sub">> => ClientId,
<<"aud">> => TokenEndpoint,
<<"aud">> => Audience,
<<"jti">> => random_string(32),
<<"iat">> => os:system_time(seconds),
<<"exp">> => os:system_time(seconds) + 30,
Expand Down
107 changes: 93 additions & 14 deletions src/oidcc_authorization.erl
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
%% <li>`url_extension' - add custom query parameters to the authorization url</li>
%% </ul>

-type error() :: {grant_type_not_supported, authorization_code}.
-type error() ::
{grant_type_not_supported, authorization_code} | par_required | oidcc_http_util:error().

%% @doc
%% Create Auth Redirect URL
Expand Down Expand Up @@ -78,18 +79,20 @@ create_redirect_url(#oidcc_client_context{} = ClientContext, Opts) ->
} =
ProviderConfiguration,

case lists:member(<<"authorization_code">>, GrantTypesSupported) of
true ->
QueryParams0 = redirect_params(ClientContext, Opts),
QueryParams = QueryParams0 ++ maps:get(url_extension, Opts, []),
QueryString = uri_string:compose_query(QueryParams),

{ok, [AuthEndpoint, <<"?">>, QueryString]};
maybe
true ?= lists:member(<<"authorization_code">>, GrantTypesSupported),
{ok, QueryParams0} ?= redirect_params(ClientContext, Opts),
QueryParams = QueryParams0 ++ maps:get(url_extension, Opts, []),
QueryString = uri_string:compose_query(QueryParams),
{ok, [AuthEndpoint, <<"?">>, QueryString]}
else
{error, Reason} ->
{error, Reason};
false ->
{error, {grant_type_not_supported, authorization_code}}
end.

-spec redirect_params(ClientContext, Opts) -> oidcc_http_util:query_params() when
-spec redirect_params(ClientContext, Opts) -> {ok, oidcc_http_util:query_params()} when
ClientContext :: oidcc_client_context:t(),
Opts :: opts().
redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opts) ->
Expand All @@ -107,7 +110,8 @@ redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opt
QueryParams4 = oidcc_scope:query_append_scope(
maps:get(scopes, Opts, [openid]), QueryParams3
),
attempt_request_object(QueryParams4, ClientContext).
QueryParams5 = attempt_request_object(QueryParams4, ClientContext),
attempt_par(QueryParams5, ClientContext, Opts).

-spec append_code_challenge(PkceVerifier, QueryParams, ClientContext) ->
oidcc_http_util:query_params()
Expand Down Expand Up @@ -136,14 +140,14 @@ append_code_challenge(CodeVerifier, QueryParams, ClientContext) ->
mode => urlsafe, padding => false
}),
[
{"code_challenge", CodeChallenge},
{"code_challenge_method", <<"S256">>}
{<<"code_challenge">>, CodeChallenge},
{<<"code_challenge_method">>, <<"S256">>}
| QueryParams
];
{false, true} ->
[
{"code_challenge", CodeVerifier},
{"code_challenge_method", <<"plain">>}
{<<"code_challenge">>, CodeVerifier},
{<<"code_challenge_method">>, <<"plain">>}
| QueryParams
];
{false, false} ->
Expand Down Expand Up @@ -257,6 +261,81 @@ attempt_request_object(QueryParams, #oidcc_client_context{
end
end.

-spec attempt_par(QueryParams, ClientContext, Opts) ->
{ok, QueryParams} | {error, error()}
when
QueryParams :: oidcc_http_util:query_params(),
ClientContext :: oidcc_client_context:t(),
Opts :: opts().
attempt_par(
_QueryParams,
#oidcc_client_context{
provider_configuration = #oidcc_provider_configuration{
require_pushed_authorization_requests = true,
pushed_authorization_request_endpoint = undefined
}
},
_Opts
) ->
{error, par_required};
attempt_par(
QueryParams,
#oidcc_client_context{
provider_configuration = #oidcc_provider_configuration{
pushed_authorization_request_endpoint = undefined
}
},
_Opts
) ->
{ok, QueryParams};
attempt_par(
QueryParams,
#oidcc_client_context{
client_id = ClientId,
provider_configuration =
#oidcc_provider_configuration{
issuer = Issuer,
token_endpoint_auth_methods_supported = SupportedAuthMethods,
token_endpoint_auth_signing_alg_values_supported = SigningAlgs,
pushed_authorization_request_endpoint = PushedAuthorizationRequestEndpoint
}
} = ClientContext,
Opts
) ->
Header0 = [{"accept", "application/json"}],

TelemetryOpts = #{
topic => [oidcc, par_request], extra_meta => #{issuer => Issuer, client_id => ClientId}
},

RequestOpts = maps:get(request_opts, Opts, #{}),
%% https://datatracker.ietf.org/doc/html/rfc9126#section-2
%% > To address that ambiguity, the issuer identifier URL of the authorization
%% > server according to [RFC8414] SHOULD be used as the value of the audience.
AuthenticationOpts = #{audience => Issuer},

maybe
{ok, {Body, Header}} ?=
oidcc_auth_util:add_client_authentication(
QueryParams,
Header0,
SupportedAuthMethods,
SigningAlgs,
AuthenticationOpts,
ClientContext
),
Request =
{PushedAuthorizationRequestEndpoint, Header, "application/x-www-form-urlencoded",
uri_string:compose_query(Body)},
{ok, {{json, ParResponse}, _Headers}} ?=
oidcc_http_util:request(post, Request, TelemetryOpts, RequestOpts),
#{<<"request_uri">> := ParRequestUri} ?= ParResponse,
{ok, [{<<"request_uri">>, ParRequestUri}, {<<"client_id">>, ClientId}]}
else
{error, Reason} -> {error, Reason};
#{} = JsonResponse -> {error, {http_error, 201, JsonResponse}}
end.

-spec essential_params(QueryParams :: oidcc_http_util:query_params()) ->
oidcc_http_util:query_params().
essential_params(QueryParams) ->
Expand Down
6 changes: 5 additions & 1 deletion src/oidcc_client_registration.erl
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@
request_uris :: [uri_string:uri_string()] | undefined,
%% OpenID Connect RP-Initiated Logout 1.0
post_logout_redirect_uris :: [uri_string:uri_string()] | undefined,
%% OAuth 2.0 Pushed Authorization Requests
require_pushed_authorization_requests :: boolean(),
%% Unknown Fields
extra_fields :: #{binary() => term()}
}.
Expand Down Expand Up @@ -293,6 +295,7 @@ encode(#oidcc_client_registration{
initiate_login_uri = InitiateLoginUri,
request_uris = RequestUris,
post_logout_redirect_uris = PostLogoutRedirectUris,
require_pushed_authorization_requests = RequirePushedAuthorizationRequests,
extra_fields = ExtraFields
}) ->
Map0 = #{
Expand Down Expand Up @@ -332,7 +335,8 @@ encode(#oidcc_client_registration{
default_acr_values => DefaultAcrValues,
initiate_login_uri => InitiateLoginUri,
request_uris => RequestUris,
post_logout_redirect_uris => PostLogoutRedirectUris
post_logout_redirect_uris => PostLogoutRedirectUris,
require_pushed_authorization_requests => RequirePushedAuthorizationRequests
},
Map1 = maps:merge(Map0, ExtraFields),
Map = maps:filter(
Expand Down
12 changes: 10 additions & 2 deletions test/oidcc_authorization_SUITE.erl
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
-module(oidcc_authorization_SUITE).

-include("oidcc_client_context.hrl").
-include("oidcc_provider_configuration.hrl").

-export([all/0]).
-export([create_redirect_url_inl_gov/1]).

Expand All @@ -14,10 +17,15 @@ create_redirect_url_inl_gov(_Config) ->
issuer => <<"https://identity-preview.inl.gov">>
}),

{ok, ClientContext} = oidcc_client_context:from_configuration_worker(
{ok, #oidcc_client_context{provider_configuration = ProviderConfiguration} = ClientContext0} = oidcc_client_context:from_configuration_worker(
InlGovPid, <<"client_id">>, <<"client_secret">>
),

%% we only want to test the URL generation, not the PAR request
ClientContext = ClientContext0#oidcc_client_context{
provider_configuration = ProviderConfiguration#oidcc_provider_configuration{
pushed_authorization_request_endpoint = undefined
}
},
{ok, Url} = oidcc_authorization:create_redirect_url(ClientContext, #{
redirect_uri => <<"https://my.server/return">>
}),
Expand Down
Loading

0 comments on commit 9f5198b

Please sign in to comment.