diff --git a/README.md b/README.md
index c5d998e..bd5172f 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ The refactoring for `v3` and the certification is funded as an
* [RP-Initiated](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)
* [JWT Secured Authorization Response Mode for OAuth 2.0 (JARM)](https://openid.net/specs/oauth-v2-jarm-final.html)
* [Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449)
+* [OAuth 2 Purpose Request Parameter](https://cdn.connectid.com.au/specifications/oauth2-purpose-01.html)
* Profiles
* [FAPI 2.0 Security Profile](https://openid.bitbucket.io/fapi/fapi-2_0-security-profile.html)
* [FAPI 2.0 Message Signing](https://openid.bitbucket.io/fapi/fapi-2_0-message-signing.html)
diff --git a/src/oidcc_authorization.erl b/src/oidcc_authorization.erl
index 5d095c7..90174cf 100644
--- a/src/oidcc_authorization.erl
+++ b/src/oidcc_authorization.erl
@@ -24,6 +24,8 @@
nonce => binary(),
pkce_verifier => binary(),
require_pkce => boolean(),
+ purpose => binary(),
+ require_purpose => boolean(),
redirect_uri => uri_string:uri_string(),
url_extension => oidcc_http_util:query_params(),
response_mode => binary()
@@ -38,6 +40,9 @@
%%
`scopes' - list of scopes to request (defaults to `[<<"openid">>]')
%% `state' - state to pass to the provider
%% `nonce' - nonce to pass to the provider
+%% `purpose' - purpose of the authorization request, see
+%% [https://cdn.connectid.com.au/specifications/oauth2-purpose-01.html]
+%% `require_purpose' - whether to require a `purpose' value
%% `pkce_verifier' - pkce verifier (random string), see
%% [https://datatracker.ietf.org/doc/html/rfc7636#section-4.1]
%% `require_pkce' - whether to require PKCE when getting the token
@@ -51,6 +56,7 @@
| par_required
| request_object_required
| pkce_verifier_required
+ | purpose_required
| no_supported_code_challenge
| oidcc_http_util:error().
@@ -105,32 +111,34 @@ create_redirect_url(#oidcc_client_context{} = ClientContext, Opts) ->
ClientContext :: oidcc_client_context:t(),
Opts :: opts().
redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opts) ->
- QueryParams =
+ QueryParams0 =
[
{<<"response_type">>, maps:get(response_type, Opts, <<"code">>)},
{<<"client_id">>, ClientId},
{<<"redirect_uri">>, maps:get(redirect_uri, Opts)}
],
- QueryParams1 = maybe_append(<<"state">>, maps:get(state, Opts, undefined), QueryParams),
+ QueryParams1 = maybe_append(<<"state">>, maps:get(state, Opts, undefined), QueryParams0),
QueryParams2 = maybe_append(<<"nonce">>, maps:get(nonce, Opts, undefined), QueryParams1),
- QueryParams3 =
+ QueryParams3 = maybe_append(<<"purpose">>, maps:get(purpose, Opts, undefined), QueryParams2),
+ QueryParams4 =
case maps:get(response_mode, Opts, <<"query">>) of
<<"query">> ->
- QueryParams2;
+ QueryParams3;
ResponseMode when is_binary(ResponseMode) ->
- [{<<"response_mode">>, ResponseMode} | QueryParams2]
+ [{<<"response_mode">>, ResponseMode} | QueryParams3]
end,
maybe
- {ok, QueryParams4} ?=
+ ok ?= validate_purpose_required(Opts),
+ {ok, QueryParams5} ?=
append_code_challenge(
- Opts, QueryParams3, ClientContext
+ Opts, QueryParams4, ClientContext
),
- QueryParams5 = oidcc_scope:query_append_scope(
- maps:get(scopes, Opts, [openid]), QueryParams4
+ QueryParams6 = oidcc_scope:query_append_scope(
+ maps:get(scopes, Opts, [openid]), QueryParams5
),
- QueryParams6 = maybe_append_dpop_jkt(QueryParams5, ClientContext),
- {ok, QueryParams7} ?= attempt_request_object(QueryParams6, ClientContext),
- attempt_par(QueryParams7, ClientContext, Opts)
+ QueryParams7 = maybe_append_dpop_jkt(QueryParams6, ClientContext),
+ {ok, QueryParams} ?= attempt_request_object(QueryParams7, ClientContext),
+ attempt_par(QueryParams, ClientContext, Opts)
end.
-spec append_code_challenge(Opts, QueryParams, ClientContext) ->
@@ -191,6 +199,15 @@ maybe_append(_Key, undefined, QueryParams) ->
maybe_append(Key, Value, QueryParams) ->
[{Key, Value} | QueryParams].
+-spec validate_purpose_required(Opts) -> ok | {error, purpose_required} when
+ Opts :: opts().
+validate_purpose_required(#{purpose := Purpose}) when is_binary(Purpose) ->
+ ok;
+validate_purpose_required(#{purpose_required := true}) ->
+ {error, purpose_required};
+validate_purpose_required(_Opts) ->
+ ok.
+
-spec maybe_append_dpop_jkt(QueryParams, ClientContext) ->
QueryParams
when
diff --git a/src/oidcc_profile.erl b/src/oidcc_profile.erl
index 412b768..2aae847 100644
--- a/src/oidcc_profile.erl
+++ b/src/oidcc_profile.erl
@@ -17,7 +17,7 @@
-export_type([opts_no_profiles/0]).
-export_type([error/0]).
--type profile() :: fapi2_security_profile | fapi2_message_signing.
+-type profile() :: fapi2_security_profile | fapi2_message_signing | fapi2_connectid_au.
-type opts() :: #{
profiles => [profile()],
require_pkce => boolean(),
@@ -92,6 +92,61 @@ apply_profiles(
%% Also require everything from FAPI2 Security Profile
Opts = Opts0#{profiles => [fapi2_security_profile | RestProfiles]},
apply_profiles(ClientContext, Opts);
+apply_profiles(
+ #oidcc_client_context{} = ClientContext0,
+ #{profiles := [fapi2_connectid_au | RestProfiles]} = Opts0
+) ->
+ %% FAPI2 ConnectID profile
+ maybe
+ %% Require everything from FAPI2 Message Signing
+ {ok, ClientContext1, Opts1} ?=
+ apply_profiles(ClientContext0, Opts0#{
+ profiles => [fapi2_message_signing | RestProfiles]
+ }),
+ %% Require `purpose' field
+ Opts2 = Opts1#{require_purpose => true},
+ %% If a PAR endpoint is present in the mTLS aliases, use that as the default
+ #oidcc_client_context{provider_configuration = Configuration1} = ClientContext1,
+ Configuration2 =
+ case Configuration1#oidcc_provider_configuration.mtls_endpoint_aliases of
+ #{
+ <<"pushed_authorization_request_endpoint">> := MtlsParEndpoint
+ } ->
+ Configuration1#oidcc_provider_configuration{
+ pushed_authorization_request_endpoint = MtlsParEndpoint
+ };
+ _ ->
+ Configuration1
+ end,
+ %% If the token endpoint is present in the mTLS aliases, use that as the default
+ Configuration3 =
+ case Configuration2#oidcc_provider_configuration.mtls_endpoint_aliases of
+ #{
+ <<"token_endpoint">> := MtlsTokenEndpoint
+ } ->
+ Configuration2#oidcc_provider_configuration{
+ token_endpoint = MtlsTokenEndpoint
+ };
+ _ ->
+ Configuration2
+ end,
+ %% If the userinfo endpoint is present in the mTLS aliases, use that as the default
+ Configuration4 =
+ case Configuration3#oidcc_provider_configuration.mtls_endpoint_aliases of
+ #{
+ <<"userinfo_endpoint">> := MtlsUserinfoEndpoint
+ } ->
+ Configuration3#oidcc_provider_configuration{
+ userinfo_endpoint = MtlsUserinfoEndpoint
+ };
+ _ ->
+ Configuration3
+ end,
+ ClientContext2 = ClientContext1#oidcc_client_context{
+ provider_configuration = Configuration4
+ },
+ {ok, ClientContext2, Opts2}
+ end;
apply_profiles(#oidcc_client_context{}, #{profiles := [UnknownProfile | _]}) ->
{error, {unknown_profile, UnknownProfile}};
apply_profiles(#oidcc_client_context{} = ClientContext, #{profiles := []} = Opts0) ->
diff --git a/test/oidcc_authorization_test.erl b/test/oidcc_authorization_test.erl
index c39583a..8eea1db 100644
--- a/test/oidcc_authorization_test.erl
+++ b/test/oidcc_authorization_test.erl
@@ -65,6 +65,9 @@ create_redirect_url_test() ->
Opts5 = maps:merge(BaseOpts, #{pkce_verifier => <<"foo">>}),
Opts6 = maps:merge(Opts5, #{require_pkce => true}),
Opts7 = maps:merge(BaseOpts, #{require_pkce => true}),
+ Opts8 = maps:merge(BaseOpts, #{purpose => <<"purpose">>}),
+ Opts9 = maps:merge(Opts8, #{purpose_required => true}),
+ Opts10 = maps:merge(BaseOpts, #{purpose_required => true}),
{ok, Url1} = oidcc_authorization:create_redirect_url(ClientContext, BaseOpts),
{ok, Url2} = oidcc_authorization:create_redirect_url(ClientContext, Opts1),
@@ -75,6 +78,8 @@ create_redirect_url_test() ->
{ok, Url7} = oidcc_authorization:create_redirect_url(PkcePlainClientContext, Opts5),
{ok, Url8} = oidcc_authorization:create_redirect_url(NoPkceClientContext, Opts5),
{ok, Url9} = oidcc_authorization:create_redirect_url(PkcePlainClientContext, Opts6),
+ {ok, Url10} = oidcc_authorization:create_redirect_url(ClientContext, Opts8),
+ {ok, Url11} = oidcc_authorization:create_redirect_url(ClientContext, Opts9),
ExpUrl1 =
<<"https://my.provider/auth?scope=openid&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn&test=id">>,
@@ -110,6 +115,12 @@ create_redirect_url_test() ->
?assertEqual(iolist_to_binary(Url9), iolist_to_binary(Url7)),
+ ExpUrl10 =
+ <<"https://my.provider/auth?scope=openid&purpose=purpose&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn&test=id">>,
+ ?assertEqual(ExpUrl10, iolist_to_binary(Url10)),
+
+ ?assertEqual(iolist_to_binary(Url11), iolist_to_binary(Url10)),
+
?assertEqual(
{error, no_supported_code_challenge},
oidcc_authorization:create_redirect_url(NoPkceClientContext, Opts6)
@@ -120,6 +131,11 @@ create_redirect_url_test() ->
oidcc_authorization:create_redirect_url(ClientContext, Opts7)
),
+ ?assertEqual(
+ {error, purpose_required},
+ oidcc_authorization:create_redirect_url(ClientContext, Opts10)
+ ),
+
ok.
create_redirect_url_with_request_object_test() ->
diff --git a/test/oidcc_client_context_test.erl b/test/oidcc_client_context_test.erl
index d2c8d45..c148434 100644
--- a/test/oidcc_client_context_test.erl
+++ b/test/oidcc_client_context_test.erl
@@ -163,6 +163,62 @@ apply_profiles_fapi2_message_signing_test() ->
ok.
+apply_profiles_fapi2_connectid_au_test() ->
+ ClientContext0 = client_context_fixture(),
+ Opts0 = #{
+ profiles => [fapi2_connectid_au]
+ },
+
+ ProfileResult = oidcc_client_context:apply_profiles(ClientContext0, Opts0),
+
+ ?assertMatch(
+ {ok, #oidcc_client_context{}, #{}},
+ ProfileResult
+ ),
+
+ {ok, ClientContext, Opts} = ProfileResult,
+
+ ?assertMatch(
+ #oidcc_client_context{
+ provider_configuration = #oidcc_provider_configuration{
+ token_endpoint = <<"https://my.provider/tls/token">>,
+ userinfo_endpoint = <<"https://my.provider/tls/userinfo">>,
+ response_types_supported = [<<"code">>],
+ response_modes_supported = [<<"jwt">>, <<"query.jwt">>],
+ id_token_signing_alg_values_supported = [<<"EdDSA">>],
+ userinfo_signing_alg_values_supported = [
+ <<"PS256">>,
+ <<"PS384">>,
+ <<"PS512">>,
+ <<"ES256">>,
+ <<"ES384">>,
+ <<"ES512">>,
+ <<"EdDSA">>
+ ],
+ code_challenge_methods_supported = [<<"S256">>],
+ require_pushed_authorization_requests = true,
+ pushed_authorization_request_endpoint = undefined,
+ authorization_response_iss_parameter_supported = true
+ }
+ },
+ ClientContext
+ ),
+
+ ?assertMatch(
+ #{
+ preferred_auth_methods := [private_key_jwt, tls_client_auth],
+ require_pkce := true,
+ require_purpose := true,
+ trusted_audiences := [],
+ request_opts := #{
+ ssl := _
+ }
+ },
+ Opts
+ ),
+
+ ok.
+
apply_profiles_unknown_test() ->
ClientContext = client_context_fixture(),
Opts = #{