Skip to content

Commit

Permalink
feat: small features to support ConnectID.com.au profile (#333)
Browse files Browse the repository at this point in the history
* feat: small features to support ConnectID.com.au profile

I can't find any good documentation about it, but this is what's
necessary to pass the conformance suite:

- require a "purpose" value in the request
- use the mTLS endpoints (with a certificate) even if we're not using
  mTLS authentication

* fixup! feat: small features to support ConnectID.com.au profile
  • Loading branch information
paulswartz authored Jan 13, 2024
1 parent bfee572 commit f0615ab
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 13 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 29 additions & 12 deletions src/oidcc_authorization.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -38,6 +40,9 @@
%% <li>`scopes' - list of scopes to request (defaults to `[<<"openid">>]')</li>
%% <li>`state' - state to pass to the provider</li>
%% <li>`nonce' - nonce to pass to the provider</li>
%% <li>`purpose' - purpose of the authorization request, see
%% [https://cdn.connectid.com.au/specifications/oauth2-purpose-01.html]</li>
%% <li>`require_purpose' - whether to require a `purpose' value</li>
%% <li>`pkce_verifier' - pkce verifier (random string), see
%% [https://datatracker.ietf.org/doc/html/rfc7636#section-4.1]</li>
%% <li>`require_pkce' - whether to require PKCE when getting the token</li>
Expand All @@ -51,6 +56,7 @@
| par_required
| request_object_required
| pkce_verifier_required
| purpose_required
| no_supported_code_challenge
| oidcc_http_util:error().

Expand Down Expand Up @@ -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) ->
Expand Down Expand Up @@ -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
Expand Down
57 changes: 56 additions & 1 deletion src/oidcc_profile.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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) ->
Expand Down
16 changes: 16 additions & 0 deletions test/oidcc_authorization_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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">>,
Expand Down Expand Up @@ -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)
Expand All @@ -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() ->
Expand Down
56 changes: 56 additions & 0 deletions test/oidcc_client_context_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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 = #{
Expand Down

0 comments on commit f0615ab

Please sign in to comment.