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 = #{