diff --git a/README.md b/README.md
index d4eb447..3b6a989 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,9 @@ The refactoring for `v3` and the certification is funded as an
* 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)
+* 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)
## Setup
diff --git a/include/oidcc_provider_configuration.hrl b/include/oidcc_provider_configuration.hrl
index 137ce0a..0265523 100644
--- a/include/oidcc_provider_configuration.hrl
+++ b/include/oidcc_provider_configuration.hrl
@@ -107,6 +107,8 @@
authorization_response_iss_parameter_supported = false :: boolean(),
%% OAuth 2.0 Demonstrating Proof of Possession (DPoP)
dpop_signing_alg_values_supported = undefined :: [binary()] | undefined,
+ %% RFC 9101 The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR)
+ require_signed_request_object = false :: boolean(),
%% Unknown Fields
extra_fields = #{} :: #{binary() => term()}
}
diff --git a/lib/oidcc/client_context.ex b/lib/oidcc/client_context.ex
index a4a4bfa..cf147d8 100644
--- a/lib/oidcc/client_context.ex
+++ b/lib/oidcc/client_context.ex
@@ -139,6 +139,45 @@ defmodule Oidcc.ClientContext do
|> record_to_struct()
end
+ @doc """
+ Apply OpenID Connect / OAuth2 Profiles to the context
+
+ See `:oidcc_client_context.apply_profiles/2` for more.
+
+ ## Examples
+
+ iex> {:ok, _pid} =
+ ...> Oidcc.ProviderConfiguration.Worker.start_link(%{
+ ...> issuer: "https://accounts.google.com",
+ ...> name: __MODULE__.GoogleConfigProvider
+ ...> })
+ ...>
+ ...> {:ok, client_context} =
+ ...> Oidcc.ClientContext.from_configuration_worker(
+ ...> __MODULE__.GoogleConfigProvider,
+ ...> "client_id",
+ ...> "client_Secret"
+ ...> )
+ ...>
+ ...> {:ok, %Oidcc.ClientContext{}, %{}} =
+ ...> Oidcc.ClientContext.apply_profiles(
+ ...> client_context,
+ ...> %{profiles: [:fapi2_message_signing]}
+ ...> )
+ """
+ @doc since: "3.2.0"
+ @spec apply_profiles(t(), :oidcc_profile.opts()) ::
+ {:ok, t(), :oidcc_profile.opts_no_profiles()} | {:error, :oidcc_client_context.error()}
+ def apply_profiles(client_context, opts) do
+ case :oidcc_client_context.apply_profiles(struct_to_record(client_context), opts) do
+ {:ok, context_record, opts} ->
+ {:ok, record_to_struct(context_record), opts}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
@impl Oidcc.RecordStruct
def record_to_struct(record) do
record
diff --git a/lib/oidcc/provider_configuration.ex b/lib/oidcc/provider_configuration.ex
index 8bb22ec..0d3e8d5 100644
--- a/lib/oidcc/provider_configuration.ex
+++ b/lib/oidcc/provider_configuration.ex
@@ -117,6 +117,7 @@ defmodule Oidcc.ProviderConfiguration do
authorization_encryption_alg_values_supported: [String.t()] | :undefined,
authorization_encryption_enc_values_supported: [String.t()] | :undefined,
dpop_signing_alg_values_supported: [String.t()] | :undefined,
+ require_signed_request_object: boolean(),
extra_fields: %{String.t() => term()}
}
diff --git a/priv/test/fixtures/fapi2-metadata.json b/priv/test/fixtures/fapi2-metadata.json
new file mode 100644
index 0000000..269abd9
--- /dev/null
+++ b/priv/test/fixtures/fapi2-metadata.json
@@ -0,0 +1,106 @@
+{
+ "issuer": "https://my.provider",
+ "authorization_endpoint": "https://my.provider/auth",
+ "registration_endpoint": "https://my.provider/register",
+ "device_authorization_endpoint": "https://my.provider/device/code",
+ "token_endpoint": "https://my.provider/token",
+ "introspection_endpoint": "https://my.provider/introspection",
+ "userinfo_endpoint": "https://my.provider/userinfo",
+ "revocation_endpoint": "https://my.provider/revoke",
+ "jwks_uri": "https://my.provider/jwks",
+ "response_types_supported": [
+ "code",
+ "token",
+ "id_token",
+ "code token",
+ "code id_token",
+ "token id_token",
+ "code token id_token",
+ "none"
+ ],
+ "subject_types_supported": [
+ "public"
+ ],
+ "id_token_signing_alg_values_supported": [
+ "none",
+ "HS256",
+ "RS256",
+ "EdDSA"
+ ],
+ "scopes_supported": [
+ "openid",
+ "email",
+ "profile"
+ ],
+ "token_endpoint_auth_methods_supported": [
+ "client_secret_post",
+ "client_secret_basic",
+ "private_key_jwt",
+ "unsupporeted_auth"
+ ],
+ "claims_supported": [
+ "aud",
+ "email",
+ "email_verified",
+ "exp",
+ "family_name",
+ "given_name",
+ "iat",
+ "iss",
+ "locale",
+ "name",
+ "picture",
+ "sub"
+ ],
+ "code_challenge_methods_supported": [
+ "plain",
+ "S256"
+ ],
+ "grant_types_supported": [
+ "authorization_code",
+ "refresh_token",
+ "urn:ietf:params:oauth:grant-type:device_code",
+ "urn:ietf:params:oauth:grant-type:jwt-bearer"
+ ],
+ "userinfo_signing_alg_values_supported": [
+ "none",
+ "HS256",
+ "RS256",
+ "RS384",
+ "RS512",
+ "PS256",
+ "PS384",
+ "PS512",
+ "ES256",
+ "ES256K",
+ "ES384",
+ "ES512",
+ "EdDSA"
+ ],
+ "userinfo_encryption_alg_values_supported": [
+ "RSA1_5",
+ "RSA-OAEP",
+ "RSA-OAEP-256",
+ "RSA-OAEP-384",
+ "RSA-OAEP-512",
+ "ECDH-ES",
+ "ECDH-ES+A128KW",
+ "ECDH-ES+A192KW",
+ "ECDH-ES+A256KW",
+ "A128KW",
+ "A192KW",
+ "A256KW",
+ "A128GCMKW",
+ "A192GCMKW",
+ "A256GCMKW",
+ "dir"
+ ],
+ "userinfo_encryption_enc_values_supported": [
+ "A128CBC-HS256",
+ "A192CBC-HS384",
+ "A256CBC-HS512",
+ "A128GCM",
+ "A192GCM",
+ "A256GCM"
+ ]
+}
diff --git a/priv/test/fixtures/jwk-ed25519.pem b/priv/test/fixtures/jwk-ed25519.pem
new file mode 100644
index 0000000..0993f81
--- /dev/null
+++ b/priv/test/fixtures/jwk-ed25519.pem
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIK2JHugMJkquFakgWVf2McF16O6Vkf3rA3PAcutRFmbK
+-----END PRIVATE KEY-----
diff --git a/src/oidcc.erl b/src/oidcc.erl
index 50c8719..1b7736b 100644
--- a/src/oidcc.erl
+++ b/src/oidcc.erl
@@ -69,15 +69,16 @@ when
Opts :: oidcc_authorization:opts() | oidcc_client_context:opts(),
Uri :: uri_string:uri_string().
create_redirect_url(ProviderConfigurationWorkerName, ClientId, ClientSecret, Opts) ->
- {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts),
+ {ClientContextOpts, OtherOpts0} = extract_client_context_opts(Opts),
maybe
- {ok, ClientContext} ?=
+ {ok, ClientContext0} ?=
oidcc_client_context:from_configuration_worker(
ProviderConfigurationWorkerName,
ClientId,
ClientSecret,
ClientContextOpts
),
+ {ok, ClientContext, OtherOpts} = oidcc_profile:apply_profiles(ClientContext0, OtherOpts0),
oidcc_authorization:create_redirect_url(ClientContext, OtherOpts)
end.
@@ -128,16 +129,19 @@ retrieve_token(
{ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts),
RefreshJwksFun = oidcc_jwt_util:refresh_jwks_fun(ProviderConfigurationWorkerName),
- OptsWithRefresh = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts),
+ OptsWithRefresh0 = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts),
maybe
- {ok, ClientContext} ?=
+ {ok, ClientContext0} ?=
oidcc_client_context:from_configuration_worker(
ProviderConfigurationWorkerName,
ClientId,
ClientSecret,
ClientContextOpts
),
+ {ok, ClientContext, OptsWithRefresh} = oidcc_profile:apply_profiles(
+ ClientContext0, OptsWithRefresh0
+ ),
oidcc_token:retrieve(AuthCode, ClientContext, OptsWithRefresh)
end.
@@ -190,16 +194,17 @@ retrieve_userinfo(
ClientSecret,
Opts
) ->
- {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts),
+ {ClientContextOpts, OtherOpts0} = extract_client_context_opts(Opts),
maybe
- {ok, ClientContext} ?=
+ {ok, ClientContext0} ?=
oidcc_client_context:from_configuration_worker(
ProviderConfigurationWorkerName,
ClientId,
ClientSecret,
ClientContextOpts
),
+ {ok, ClientContext, OtherOpts} = oidcc_profile:apply_profiles(ClientContext0, OtherOpts0),
oidcc_userinfo:retrieve(Token, ClientContext, OtherOpts)
end.
@@ -260,16 +265,19 @@ refresh_token(
{ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts),
RefreshJwksFun = oidcc_jwt_util:refresh_jwks_fun(ProviderConfigurationWorkerName),
- OptsWithRefresh = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts),
+ OptsWithRefresh0 = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts),
maybe
- {ok, ClientContext} ?=
+ {ok, ClientContext0} ?=
oidcc_client_context:from_configuration_worker(
ProviderConfigurationWorkerName,
ClientId,
ClientSecret,
ClientContextOpts
),
+ {ok, ClientContext, OptsWithRefresh} = oidcc_profile:apply_profiles(
+ ClientContext0, OptsWithRefresh0
+ ),
oidcc_token:refresh(RefreshToken, ClientContext, OptsWithRefresh)
end.
@@ -314,16 +322,17 @@ introspect_token(
ClientSecret,
Opts
) ->
- {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts),
+ {ClientContextOpts, OtherOpts0} = extract_client_context_opts(Opts),
maybe
- {ok, ClientContext} ?=
+ {ok, ClientContext0} ?=
oidcc_client_context:from_configuration_worker(
ProviderConfigurationWorkerName,
ClientId,
ClientSecret,
ClientContextOpts
),
+ {ok, ClientContext, OtherOpts} = oidcc_profile:apply_profiles(ClientContext0, OtherOpts0),
oidcc_token_introspection:introspect(Token, ClientContext, OtherOpts)
end.
@@ -371,16 +380,19 @@ jwt_profile_token(Subject, ProviderConfigurationWorkerName, ClientId, ClientSecr
{ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts),
RefreshJwksFun = oidcc_jwt_util:refresh_jwks_fun(ProviderConfigurationWorkerName),
- OptsWithRefresh = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts),
+ OptsWithRefresh0 = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts),
maybe
- {ok, ClientContext} ?=
+ {ok, ClientContext0} ?=
oidcc_client_context:from_configuration_worker(
ProviderConfigurationWorkerName,
ClientId,
ClientSecret,
ClientContextOpts
),
+ {ok, ClientContext, OptsWithRefresh} = oidcc_profile:apply_profiles(
+ ClientContext0, OptsWithRefresh0
+ ),
oidcc_token:jwt_profile(Subject, ClientContext, Jwk, OptsWithRefresh)
end.
@@ -415,16 +427,19 @@ client_credentials_token(ProviderConfigurationWorkerName, ClientId, ClientSecret
{ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts),
RefreshJwksFun = oidcc_jwt_util:refresh_jwks_fun(ProviderConfigurationWorkerName),
- OptsWithRefresh = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts),
+ OptsWithRefresh0 = maps_put_new(refresh_jwks, RefreshJwksFun, OtherOpts),
maybe
- {ok, ClientContext} ?=
+ {ok, ClientContext0} ?=
oidcc_client_context:from_configuration_worker(
ProviderConfigurationWorkerName,
ClientId,
ClientSecret,
ClientContextOpts
),
+ {ok, ClientContext, OptsWithRefresh} = oidcc_profile:apply_profiles(
+ ClientContext0, OptsWithRefresh0
+ ),
oidcc_token:client_credentials(ClientContext, OptsWithRefresh)
end.
@@ -464,16 +479,17 @@ when
ClientId :: binary(),
Opts :: oidcc_logout:initiate_url_opts() | oidcc_client_context:unauthenticated_opts().
initiate_logout_url(Token, ProviderConfigurationWorkerName, ClientId, Opts) ->
- {ClientContextOpts, OtherOpts} = extract_client_context_opts(Opts),
+ {ClientContextOpts, OtherOpts0} = extract_client_context_opts(Opts),
maybe
- {ok, ClientContext} ?=
+ {ok, ClientContext0} ?=
oidcc_client_context:from_configuration_worker(
ProviderConfigurationWorkerName,
ClientId,
unauthenticated,
ClientContextOpts
),
+ {ok, ClientContext, OtherOpts} = oidcc_profile:apply_profiles(ClientContext0, OtherOpts0),
oidcc_logout:initiate_url(Token, ClientContext, OtherOpts)
end.
diff --git a/src/oidcc_authorization.erl b/src/oidcc_authorization.erl
index 747cabd..8dd8063 100644
--- a/src/oidcc_authorization.erl
+++ b/src/oidcc_authorization.erl
@@ -23,7 +23,8 @@
state => binary(),
nonce => binary(),
pkce_verifier => binary(),
- redirect_uri := uri_string:uri_string(),
+ require_pkce => boolean(),
+ redirect_uri => uri_string:uri_string(),
url_extension => oidcc_http_util:query_params()
}.
%% Configure authorization redirect url
@@ -38,12 +39,18 @@
%%
`nonce' - nonce to pass to the provider
%% `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
%% `redirect_uri' - redirect target after authorization is completed
%% `url_extension' - add custom query parameters to the authorization url
%%
-type error() ::
- {grant_type_not_supported, authorization_code} | par_required | oidcc_http_util:error().
+ {grant_type_not_supported, authorization_code}
+ | par_required
+ | request_object_required
+ | pkce_verifier_required
+ | no_supported_code_challenge
+ | oidcc_http_util:error().
%% @doc
%% Create Auth Redirect URL
@@ -104,31 +111,35 @@ redirect_params(#oidcc_client_context{client_id = ClientId} = ClientContext, Opt
],
QueryParams1 = maybe_append(<<"state">>, maps:get(state, Opts, undefined), QueryParams),
QueryParams2 = maybe_append(<<"nonce">>, maps:get(nonce, Opts, undefined), QueryParams1),
- QueryParams3 = append_code_challenge(
- maps:get(pkce_verifier, Opts, none), QueryParams2, ClientContext
- ),
- QueryParams4 = oidcc_scope:query_append_scope(
- maps:get(scopes, Opts, [openid]), QueryParams3
- ),
- QueryParams5 = maybe_append_dpop_jkt(QueryParams4, ClientContext),
- QueryParams6 = attempt_request_object(QueryParams5, ClientContext),
- attempt_par(QueryParams6, ClientContext, Opts).
+ maybe
+ {ok, QueryParams3} ?=
+ append_code_challenge(
+ Opts, QueryParams2, ClientContext
+ ),
+ QueryParams4 = oidcc_scope:query_append_scope(
+ maps:get(scopes, Opts, [openid]), QueryParams3
+ ),
+ QueryParams5 = maybe_append_dpop_jkt(QueryParams4, ClientContext),
+ {ok, QueryParams6} ?= attempt_request_object(QueryParams5, ClientContext),
+ attempt_par(QueryParams6, ClientContext, Opts)
+ end.
--spec append_code_challenge(PkceVerifier, QueryParams, ClientContext) ->
- oidcc_http_util:query_params()
+-spec append_code_challenge(Opts, QueryParams, ClientContext) ->
+ {ok, oidcc_http_util:query_params()} | {error, error()}
when
- PkceVerifier :: binary() | none,
+ Opts :: opts(),
QueryParams :: oidcc_http_util:query_params(),
ClientContext :: oidcc_client_context:t().
-append_code_challenge(none, QueryParams, _ClientContext) ->
- QueryParams;
-append_code_challenge(CodeVerifier, QueryParams, ClientContext) ->
+append_code_challenge(#{pkce_verifier := CodeVerifier} = Opts, QueryParams, ClientContext) ->
#oidcc_client_context{provider_configuration = ProviderConfiguration} = ClientContext,
#oidcc_provider_configuration{code_challenge_methods_supported = CodeChallengeMethodsSupported} =
ProviderConfiguration,
+ RequirePkce = maps:get(require_pkce, Opts, false),
case CodeChallengeMethodsSupported of
+ undefined when RequirePkce =:= true ->
+ {error, no_supported_code_challenge};
undefined ->
- QueryParams;
+ {ok, QueryParams};
Methods when is_list(Methods) ->
case
{
@@ -140,21 +151,27 @@ append_code_challenge(CodeVerifier, QueryParams, ClientContext) ->
CodeChallenge = base64:encode(crypto:hash(sha256, CodeVerifier), #{
mode => urlsafe, padding => false
}),
- [
+ {ok, [
{<<"code_challenge">>, CodeChallenge},
{<<"code_challenge_method">>, <<"S256">>}
| QueryParams
- ];
+ ]};
{false, true} ->
- [
+ {ok, [
{<<"code_challenge">>, CodeVerifier},
{<<"code_challenge_method">>, <<"plain">>}
| QueryParams
- ];
+ ]};
+ {false, false} when RequirePkce =:= true ->
+ {error, no_supported_code_challenge};
{false, false} ->
- QueryParams
+ {ok, QueryParams}
end
- end.
+ end;
+append_code_challenge(#{require_pkce := true}, _QueryParams, _ClientContext) ->
+ {error, pkce_verifier_required};
+append_code_challenge(_Opts, QueryParams, _ClientContext) ->
+ {ok, QueryParams}.
-spec maybe_append(Key, Value, QueryParams) -> QueryParams when
Key :: unicode:chardata(),
@@ -185,15 +202,11 @@ maybe_append_dpop_jkt(
maybe_append_dpop_jkt(QueryParams, _ClientContext) ->
QueryParams.
--spec attempt_request_object(QueryParams, ClientContext) -> QueryParams when
+-spec attempt_request_object(QueryParams, ClientContext) ->
+ {ok, QueryParams} | {error, error()}
+when
QueryParams :: oidcc_http_util:query_params(),
ClientContext :: oidcc_client_context:t().
-attempt_request_object(QueryParams, #oidcc_client_context{
- provider_configuration = #oidcc_provider_configuration{request_parameter_supported = false}
-}) ->
- QueryParams;
-attempt_request_object(QueryParams, #oidcc_client_context{client_secret = unauthenticated}) ->
- QueryParams;
attempt_request_object(QueryParams, #oidcc_client_context{
client_id = ClientId,
client_secret = ClientSecret,
@@ -201,12 +214,13 @@ attempt_request_object(QueryParams, #oidcc_client_context{
provider_configuration = #oidcc_provider_configuration{
issuer = Issuer,
request_parameter_supported = true,
+ require_signed_request_object = RequireSignedRequestObject,
request_object_signing_alg_values_supported = SigningAlgSupported0,
request_object_encryption_alg_values_supported = EncryptionAlgSupported0,
request_object_encryption_enc_values_supported = EncryptionEncSupported0
},
jwks = Jwks
-}) ->
+}) when ClientSecret =/= unauthenticated ->
SigningAlgSupported =
case SigningAlgSupported0 of
undefined -> [];
@@ -264,8 +278,10 @@ attempt_request_object(QueryParams, #oidcc_client_context{
Jwt = jose_jwt:from(Claims),
case oidcc_jwt_util:sign(Jwt, SigningJwks, deprioritize_none_alg(SigningAlgSupported)) of
+ {error, no_supported_alg_or_key} when RequireSignedRequestObject =:= true ->
+ {error, request_object_required};
{error, no_supported_alg_or_key} ->
- QueryParams;
+ {ok, QueryParams};
{ok, SignedRequestObject} ->
case
oidcc_jwt_util:encrypt(
@@ -276,11 +292,17 @@ attempt_request_object(QueryParams, #oidcc_client_context{
)
of
{ok, EncryptedRequestObject} ->
- [{<<"request">>, EncryptedRequestObject} | essential_params(QueryParams)];
+ {ok, [{<<"request">>, EncryptedRequestObject} | essential_params(QueryParams)]};
{error, no_supported_alg_or_key} ->
- [{<<"request">>, SignedRequestObject} | essential_params(QueryParams)]
+ {ok, [{<<"request">>, SignedRequestObject} | essential_params(QueryParams)]}
end
- end.
+ end;
+attempt_request_object(_QueryParams, #oidcc_client_context{
+ provider_configuration = #oidcc_provider_configuration{require_signed_request_object = true}
+}) ->
+ {error, request_object_required};
+attempt_request_object(QueryParams, _ClientContext) ->
+ {ok, QueryParams}.
-spec attempt_par(QueryParams, ClientContext, Opts) ->
{ok, QueryParams} | {error, error()}
diff --git a/src/oidcc_client_context.erl b/src/oidcc_client_context.erl
index 225f389..54f5e92 100644
--- a/src/oidcc_client_context.erl
+++ b/src/oidcc_client_context.erl
@@ -34,6 +34,7 @@
-export([from_configuration_worker/4]).
-export([from_manual/4]).
-export([from_manual/5]).
+-export([apply_profiles/2]).
-type t() :: authenticated_t() | unauthenticated_t().
@@ -227,3 +228,37 @@ from_manual(
client_secret = ClientSecret,
client_jwks = maps:get(client_jwks, Opts, none)
}.
+
+%% @doc Apply OpenID Connect / OAuth2 Profiles to the context
+%%
+%% Currently, the only supported profiles are:
+%% - `fapi2_security_profile' - https://openid.bitbucket.io/fapi/fapi-2_0-security-profile.html
+%% - `fapi2_message_signing' - https://openid.bitbucket.io/fapi/fapi-2_0-message-signing.html
+%%
+%% It returns an updated `#oidcc_client_context{}' record and a map of options to
+%% be merged into the `oidcc_authorization` and `oidcc_token` functions.
+%%
+%% Examples
+%%
+%% ```
+%% ClientContext = #oidcc_client_context{} = oidcc_client_context:from_...(...),
+%%
+%% {#oidcc_client_context{} = ClientContext1, Opts} = oidcc_client_context:apply_profiles(
+%% ClientContext,
+%% #{
+%% profiles => [fapi2_message_signing]
+%% }),
+%%
+%% {ok, Uri} = oidcc_authorization:create_redirect_uri(
+%% ClientContext1,
+%% maps:merge(Opts, #{...})
+%% ).
+%% '''
+%% @end
+%% @since 3.2.0
+-spec apply_profiles(ClientContext, oidcc_profile:opts()) ->
+ {ok, ClientContext, oidcc_profile:opts_no_profiles()} | {error, oidcc_profile:error()}
+when
+ ClientContext :: oidcc_client_context:t().
+apply_profiles(ClientContext, Opts) ->
+ oidcc_profile:apply_profiles(ClientContext, Opts).
diff --git a/src/oidcc_profile.erl b/src/oidcc_profile.erl
new file mode 100644
index 0000000..fc5a9bf
--- /dev/null
+++ b/src/oidcc_profile.erl
@@ -0,0 +1,186 @@
+%%%-------------------------------------------------------------------
+%% @doc OpenID Profile Utilities
+%% @end
+%% @since 3.2.0
+%%%-------------------------------------------------------------------
+-module(oidcc_profile).
+
+-feature(maybe_expr, enable).
+
+-include("oidcc_client_context.hrl").
+-include("oidcc_provider_configuration.hrl").
+
+-export([apply_profiles/2]).
+
+-export_type([profile/0]).
+-export_type([opts/0]).
+-export_type([opts_no_profiles/0]).
+-export_type([error/0]).
+
+-type profile() :: fapi2_security_profile | fapi2_message_signing.
+-type opts() :: #{
+ profiles => [profile()],
+ require_pkce => boolean(),
+ trusted_audiences => [binary()] | any,
+ preferred_auth_methods => [oidcc_auth_util:auth_method()]
+}.
+-type opts_no_profiles() :: #{
+ require_pkce => boolean(),
+ trusted_audiences => [binary()] | any,
+ preferred_auth_methods => [oidcc_auth_util:auth_method()]
+}.
+-type error() :: {unknown_profile, atom()}.
+
+%% @private
+-spec apply_profiles(ClientContext, opts()) ->
+ {ok, ClientContext, opts_no_profiles()} | {error, error()}
+when
+ ClientContext :: oidcc_client_context:t().
+apply_profiles(
+ #oidcc_client_context{} = ClientContext0,
+ #{profiles := [fapi2_security_profile | RestProfiles]} = Opts0
+) ->
+ %% FAPI2 Security Profile
+ %% - https://openid.bitbucket.io/fapi/fapi-2_0-security-profile.html
+ {ClientContext1, Opts1} = enforce_s256_pkce(ClientContext0, Opts0),
+ ClientContext2 = limit_response_types([<<"code">>], ClientContext1),
+ ClientContext3 = enforce_par(ClientContext2),
+ ClientContext4 = enforce_iss_parameter(ClientContext3),
+ ClientContext = limit_signing_alg_values(
+ [
+ <<"PS256">>,
+ <<"PS384">>,
+ <<"PS512">>,
+ <<"ES256">>,
+ <<"ES384">>,
+ <<"ES512">>,
+ <<"EdDSA">>
+ ],
+ ClientContext4
+ ),
+ Opts2 = Opts1#{profiles => RestProfiles},
+ Opts3 = map_put_new(trusted_audiences, [], Opts2),
+ %% TODO include tls_client_auth">> here when it's supported by the library.
+ Opts = map_put_new(preferred_auth_methods, [private_key_jwt], Opts3),
+ apply_profiles(ClientContext, Opts);
+apply_profiles(
+ #oidcc_client_context{} = ClientContext,
+ #{profiles := [fapi2_message_signing | RestProfiles]} = Opts0
+) ->
+ %% FAPI2 Message Signing:
+ %% - https://openid.bitbucket.io/fapi/fapi-2_0-message- signing.html
+
+ %% TODO force require_signed_request_object once the conformance suite can
+ %% validate it (currently, the suite fails if this is enabled)
+ %% TODO limit response_mode_supported to [<<"jwt">>] once JARM is supported.
+ %% This is required by the spec, but not currently by the conformance suite.
+ %% TODO require signed token introspection responses
+
+ %% Also require everything from FAPI2 Security Profile
+ Opts = Opts0#{profiles => [fapi2_security_profile | RestProfiles]},
+ apply_profiles(ClientContext, Opts);
+apply_profiles(#oidcc_client_context{}, #{profiles := [UnknownProfile | _]}) ->
+ {error, {unknown_profile, UnknownProfile}};
+apply_profiles(#oidcc_client_context{} = ClientContext, #{profiles := []} = Opts0) ->
+ Opts = maps:remove(profiles, Opts0),
+ apply_profiles(ClientContext, Opts);
+apply_profiles(#oidcc_client_context{} = ClientContext, #{} = Opts) ->
+ {ok, ClientContext, Opts}.
+
+enforce_s256_pkce(ClientContext0, Opts0) ->
+ #oidcc_client_context{
+ provider_configuration =
+ ProviderConfiguration0 = #oidcc_provider_configuration{
+ code_challenge_methods_supported = CodeChallengeMethodsSupported
+ }
+ } = ClientContext0,
+ ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{
+ code_challenge_methods_supported = limit_values([<<"S256">>], CodeChallengeMethodsSupported)
+ },
+ ClientContext = ClientContext0#oidcc_client_context{
+ provider_configuration = ProviderConfiguration
+ },
+ Opts = Opts0#{require_pkce => true},
+ {ClientContext, Opts}.
+
+limit_response_types(Types, ClientContext0) ->
+ #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0,
+ #oidcc_provider_configuration{
+ response_types_supported = ResponseTypes
+ } = ProviderConfiguration0,
+ ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{
+ response_types_supported = limit_values(Types, ResponseTypes)
+ },
+ ClientContext = ClientContext0#oidcc_client_context{
+ provider_configuration = ProviderConfiguration
+ },
+ ClientContext.
+
+enforce_par(ClientContext0) ->
+ #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0,
+ ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{
+ require_pushed_authorization_requests = true
+ },
+ ClientContext = ClientContext0#oidcc_client_context{
+ provider_configuration = ProviderConfiguration
+ },
+ ClientContext.
+
+enforce_iss_parameter(ClientContext0) ->
+ #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0,
+ ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{
+ authorization_response_iss_parameter_supported = true
+ },
+ ClientContext = ClientContext0#oidcc_client_context{
+ provider_configuration = ProviderConfiguration
+ },
+ ClientContext.
+
+limit_signing_alg_values(AlgSupported, ClientContext0) ->
+ #oidcc_client_context{provider_configuration = ProviderConfiguration0} = ClientContext0,
+ #oidcc_provider_configuration{
+ id_token_signing_alg_values_supported = IdAlg,
+ userinfo_signing_alg_values_supported = UserinfoAlg,
+ request_object_signing_alg_values_supported = RequestObjectAlg,
+ token_endpoint_auth_signing_alg_values_supported = TokenAlg,
+ revocation_endpoint_auth_signing_alg_values_supported = RevocationAlg,
+ introspection_endpoint_auth_signing_alg_values_supported = IntrospectionAlg,
+ authorization_signing_alg_values_supported = AuthorizationAlg,
+ dpop_signing_alg_values_supported = DpopAlg
+ } = ProviderConfiguration0,
+ ProviderConfiguration = ProviderConfiguration0#oidcc_provider_configuration{
+ id_token_signing_alg_values_supported = limit_values(AlgSupported, IdAlg),
+ userinfo_signing_alg_values_supported = limit_values(AlgSupported, UserinfoAlg),
+ request_object_signing_alg_values_supported = limit_values(AlgSupported, RequestObjectAlg),
+ token_endpoint_auth_signing_alg_values_supported = limit_values(AlgSupported, TokenAlg),
+ revocation_endpoint_auth_signing_alg_values_supported = limit_values(
+ AlgSupported, RevocationAlg
+ ),
+ introspection_endpoint_auth_signing_alg_values_supported = limit_values(
+ AlgSupported, IntrospectionAlg
+ ),
+ authorization_signing_alg_values_supported = limit_values(AlgSupported, AuthorizationAlg),
+ dpop_signing_alg_values_supported = limit_values(AlgSupported, DpopAlg)
+ },
+ ClientContext = ClientContext0#oidcc_client_context{
+ provider_configuration = ProviderConfiguration
+ },
+ ClientContext.
+
+limit_values(_Limit, undefined) ->
+ undefined;
+limit_values(Limit, Values) ->
+ case [V || V <- Values, lists:member(V, Limit)] of
+ [] ->
+ undefined;
+ Filtered ->
+ Filtered
+ end.
+
+map_put_new(Key, Value, Map) ->
+ case Map of
+ #{Key := _} ->
+ Map;
+ _ ->
+ Map#{Key => Value}
+ end.
diff --git a/src/oidcc_provider_configuration.erl b/src/oidcc_provider_configuration.erl
index 6915568..060f276 100644
--- a/src/oidcc_provider_configuration.erl
+++ b/src/oidcc_provider_configuration.erl
@@ -123,6 +123,7 @@
authorization_encryption_enc_values_supported :: [binary()] | undefined,
authorization_response_iss_parameter_supported :: boolean(),
dpop_signing_alg_values_supported :: [binary()] | undefined,
+ require_signed_request_object :: boolean(),
extra_fields :: #{binary() => term()}
}.
%% Record containing OpenID and OAuth 2.0 Configuration
@@ -357,7 +358,8 @@ decode_configuration(Configuration0, Opts) ->
AuthorizationEncryptionEncValuesSupported,
authorization_response_iss_parameter_supported :=
AuthorizationResponseIssParameterSupported,
- dpop_signing_alg_values_supported := DpopSigningAlgValuesSupported
+ dpop_signing_alg_values_supported := DpopSigningAlgValuesSupported,
+ require_signed_request_object := RequireSignedRequestObject
},
ExtraFields
}} ?=
@@ -466,7 +468,9 @@ decode_configuration(Configuration0, Opts) ->
{optional, authorization_response_iss_parameter_supported, false,
fun oidcc_decode_util:parse_setting_boolean/2},
{optional, dpop_signing_alg_values_supported, undefined,
- fun parse_token_signing_alg_values_no_none/2}
+ fun parse_token_signing_alg_values_no_none/2},
+ {optional, require_signed_request_object, false,
+ fun oidcc_decode_util:parse_setting_boolean/2}
],
#{}
),
@@ -542,6 +546,7 @@ decode_configuration(Configuration0, Opts) ->
authorization_response_iss_parameter_supported =
AuthorizationResponseIssParameterSupported,
dpop_signing_alg_values_supported = DpopSigningAlgValuesSupported,
+ require_signed_request_object = RequireSignedRequestObject,
extra_fields = ExtraFields
}}
end.
diff --git a/src/oidcc_token.erl b/src/oidcc_token.erl
index 7fe4b16..28c1915 100644
--- a/src/oidcc_token.erl
+++ b/src/oidcc_token.erl
@@ -98,15 +98,17 @@
-type retrieve_opts() ::
#{
pkce_verifier => binary(),
+ require_pkce => boolean(),
nonce => binary() | any,
scope => oidcc_scope:scopes(),
preferred_auth_methods => [oidcc_auth_util:auth_method(), ...],
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
- redirect_uri := uri_string:uri_string(),
+ 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(),
- dpop_nonce => binary()
+ dpop_nonce => binary(),
+ trusted_audiences => [binary()] | any
}.
%% Options for retrieving a token
%%
@@ -118,6 +120,7 @@
%% `pkce_verifier' - pkce verifier (random string previously given to
%% `oidcc_authorization'), see
%% [https://datatracker.ietf.org/doc/html/rfc7636#section-4.1]
+%% `require_pkce' - whether to require PKCE when getting the token
%% `nonce' - Nonce to check
%% `scope' - Scope to store with the token
%% `refresh_jwks' - How to handle tokens with an unknown `kid'.
@@ -125,6 +128,8 @@
%% `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
+%% `trusted_audiences' - if present, a list of additional audience values to
+%% accept. Defaults to `any' which allows any additional values
%%
-type refresh_opts_no_sub() ::
@@ -178,6 +183,7 @@
-type error() ::
{missing_claim, MissingClaim :: binary(), Claims :: oidcc_jwt_util:claims()}
+ | pkce_verifier_required
| no_supported_auth_method
| bad_access_token_hash
| sub_invalid
@@ -697,13 +703,11 @@ when
TokenRecord :: id(),
NoneUsed :: boolean().
extract_id_token(TokenMap, ClientContext, Opts) ->
- Nonce = maps:get(nonce, Opts, any),
-
case maps:get(<<"id_token">>, TokenMap, none) of
none ->
{ok, {none, false}};
Token when is_binary(Token) ->
- case validate_id_token(Token, ClientContext, Nonce) of
+ case validate_id_token(Token, ClientContext, Opts) of
{ok, OkClaims} ->
{ok, {#oidcc_token_id{token = Token, claims = OkClaims}, false}};
{error, {none_alg_used, NoneClaims}} ->
@@ -755,14 +759,19 @@ verify_access_token_map_hash(#oidcc_token{}) ->
%% '''
%% @end
%% @since 3.0.0
--spec validate_id_token(IdToken, ClientContext, Nonce) ->
+-spec validate_id_token(IdToken, ClientContext, NonceOrOpts) ->
{ok, Claims} | {error, error()}
when
IdToken :: binary(),
ClientContext :: oidcc_client_context:t(),
+ NonceOrOpts :: Nonce | retrieve_opts(),
Nonce :: binary() | any,
Claims :: oidcc_jwt_util:claims().
-validate_id_token(IdToken, ClientContext, Nonce) ->
+validate_id_token(IdToken, ClientContext, Nonce) when is_binary(Nonce) ->
+ validate_id_token(IdToken, ClientContext, #{nonce => Nonce});
+validate_id_token(IdToken, ClientContext, any) ->
+ validate_id_token(IdToken, ClientContext, #{nonce => any});
+validate_id_token(IdToken, ClientContext, Opts) when is_map(Opts) ->
#oidcc_client_context{
provider_configuration = Configuration,
jwks = #jose_jwk{} = Jwks,
@@ -775,6 +784,10 @@ validate_id_token(IdToken, ClientContext, Nonce) ->
issuer = Issuer
} =
Configuration,
+
+ Nonce = maps:get(nonce, Opts, any),
+ TrustedAudience = maps:get(trusted_audiences, Opts, any),
+
maybe
ExpClaims0 = [{<<"iss">>, Issuer}],
ExpClaims =
@@ -808,7 +821,7 @@ validate_id_token(IdToken, ClientContext, Nonce) ->
end,
ok ?= oidcc_jwt_util:verify_claims(Claims, ExpClaims),
ok ?= verify_missing_required_claims(Claims),
- ok ?= verify_aud_claim(Claims, ClientId),
+ ok ?= verify_aud_claim(Claims, ClientId, TrustedAudience),
ok ?= verify_azp_claim(Claims, ClientId),
ok ?= verify_exp_claim(Claims),
ok ?= verify_nbf_claim(Claims),
@@ -871,16 +884,27 @@ authorization_headers(
),
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) ->
+-spec verify_aud_claim(Claims, ClientId, TrustedAudience) -> ok | {error, error()} when
+ Claims :: oidcc_jwt_util:claims(), ClientId :: binary(), TrustedAudience :: [binary()] | any.
+verify_aud_claim(#{<<"aud">> := ClientId}, ClientId, _TrustedAudience) ->
+ ok;
+verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId, any) when is_list(Audience) ->
case lists:member(ClientId, Audience) of
true -> ok;
false -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}}
end;
-verify_aud_claim(#{<<"aud">> := ClientId}, ClientId) ->
- ok;
-verify_aud_claim(Claims, ClientId) ->
+verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId, TrustedAudience0) when
+ is_list(Audience)
+->
+ TrustedAudience = [ClientId | TrustedAudience0],
+ maybe
+ true ?= lists:member(ClientId, Audience),
+ [] ?= [A || A <- Audience, not lists:member(A, TrustedAudience)],
+ ok
+ else
+ _ -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}}
+ end;
+verify_aud_claim(Claims, ClientId, _TrustedAudience) ->
{error, {missing_claim, {<<"aud">>, ClientId}, Claims}}.
-spec verify_azp_claim(Claims, ClientId) -> ok | {error, error()} when
@@ -943,6 +967,10 @@ when
Opts :: retrieve_opts() | refresh_opts(),
TelemetryOpts :: oidcc_http_util:telemetry_opts(),
AuthenticateClient :: boolean().
+retrieve_a_token(
+ _QsBodyIn, none, _ClientContext, #{require_pkce := true}, _TelemetryOpts, _AuthenticateClient
+) ->
+ {error, pkce_verifier_required};
retrieve_a_token(QsBodyIn, PkceVerifier, ClientContext, Opts, TelemetryOpts, AuthenticateClient) ->
#oidcc_client_context{provider_configuration = Configuration} =
ClientContext,
diff --git a/test/oidcc_authorization_test.erl b/test/oidcc_authorization_test.erl
index 24dcfb6..5ef72c3 100644
--- a/test/oidcc_authorization_test.erl
+++ b/test/oidcc_authorization_test.erl
@@ -63,6 +63,8 @@ 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}),
{ok, Url1} = oidcc_authorization:create_redirect_url(ClientContext, BaseOpts),
{ok, Url2} = oidcc_authorization:create_redirect_url(ClientContext, Opts1),
@@ -72,6 +74,7 @@ create_redirect_url_test() ->
{ok, Url6} = oidcc_authorization:create_redirect_url(ClientContext, Opts5),
{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),
ExpUrl1 =
<<"https://my.provider/auth?scope=openid&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn&test=id">>,
@@ -105,6 +108,18 @@ create_redirect_url_test() ->
<<"https://my.provider/auth?scope=openid&response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fmy.server%2Freturn&test=id">>,
?assertEqual(ExpUrl8, iolist_to_binary(Url8)),
+ ?assertEqual(iolist_to_binary(Url9), iolist_to_binary(Url7)),
+
+ ?assertEqual(
+ {error, no_supported_code_challenge},
+ oidcc_authorization:create_redirect_url(NoPkceClientContext, Opts6)
+ ),
+
+ ?assertEqual(
+ {error, pkce_verifier_required},
+ oidcc_authorization:create_redirect_url(ClientContext, Opts7)
+ ),
+
ok.
create_redirect_url_with_request_object_test() ->
@@ -473,6 +488,39 @@ create_redirect_url_with_missing_config_request_object_test() ->
ok.
+create_redirect_url_with_missing_config_request_object_required_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{
+ request_parameter_supported = true,
+ require_signed_request_object = true
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"at_least_32_character_client_secret">>,
+
+ Jwks0 = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk.pem"),
+ Jwks = Jwks0#jose_jwk{fields = #{<<"use">> => <<"sig">>}},
+
+ RedirectUri = <<"https://my.server/return">>,
+
+ ClientContext =
+ oidcc_client_context:from_manual(Configuration, Jwks, ClientId, ClientSecret),
+
+ ?assertEqual(
+ {error, request_object_required},
+ oidcc_authorization:create_redirect_url(ClientContext, #{
+ redirect_uri => RedirectUri
+ })
+ ),
+
+ ok.
+
create_redirect_url_with_request_object_only_none_alg_test() ->
PrivDir = code:priv_dir(oidcc),
diff --git a/test/oidcc_client_context_test.erl b/test/oidcc_client_context_test.erl
index 3e036e8..fbb14e0 100644
--- a/test/oidcc_client_context_test.erl
+++ b/test/oidcc_client_context_test.erl
@@ -1,6 +1,11 @@
-module(oidcc_client_context_test).
-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_client_context.hrl").
+-include_lib("oidcc/include/oidcc_provider_configuration.hrl").
provider_not_running_test() ->
?assertMatch(
@@ -12,3 +17,126 @@ provider_not_running_test() ->
)
),
ok.
+
+apply_profiles_fapi2_security_profile_test() ->
+ ClientContext0 = client_context_fixture(),
+ Opts0 = #{
+ profiles => [fapi2_security_profile]
+ },
+
+ 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{
+ response_types_supported = [<<"code">>],
+ 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,
+ authorization_response_iss_parameter_supported = true
+ }
+ },
+ ClientContext
+ ),
+
+ ?assertMatch(
+ #{
+ preferred_auth_methods := [private_key_jwt],
+ require_pkce := true,
+ trusted_audiences := []
+ },
+ Opts
+ ),
+
+ ok.
+
+apply_profiles_fapi2_message_signing_test() ->
+ ClientContext0 = client_context_fixture(),
+ Opts0 = #{
+ profiles => [fapi2_message_signing]
+ },
+
+ 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{
+ response_types_supported = [<<"code">>],
+ 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,
+ authorization_response_iss_parameter_supported = true
+ }
+ },
+ ClientContext
+ ),
+
+ ?assertMatch(
+ #{
+ preferred_auth_methods := [private_key_jwt],
+ require_pkce := true,
+ trusted_audiences := []
+ },
+ Opts
+ ),
+
+ ok.
+
+apply_profiles_unknown_test() ->
+ ClientContext = client_context_fixture(),
+ Opts = #{
+ profiles => [unknown]
+ },
+
+ ?assertMatch(
+ {error, {unknown_profile, unknown}},
+ oidcc_client_context:apply_profiles(ClientContext, Opts)
+ ),
+
+ ok.
+
+client_context_fixture() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/fapi2-metadata.json"),
+ {ok, #oidcc_provider_configuration{} = Configuration} =
+ oidcc_provider_configuration:decode_configuration(jose:decode(ConfigurationBinary)),
+
+ Jwks = jose_jwk:from_pem_file(PrivDir ++ "/test/fixtures/jwk-ed25519.pem"),
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+
+ oidcc_client_context:from_manual(Configuration, Jwks, ClientId, ClientSecret).
diff --git a/test/oidcc_token_test.erl b/test/oidcc_token_test.erl
index 9135b48..d36945d 100644
--- a/test/oidcc_token_test.erl
+++ b/test/oidcc_token_test.erl
@@ -4,6 +4,7 @@
-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_client_context.hrl").
-include_lib("oidcc/include/oidcc_provider_configuration.hrl").
-include_lib("oidcc/include/oidcc_token.hrl").
@@ -1601,3 +1602,129 @@ authorization_headers_test() ->
DpopJwtWithNonce
),
ok.
+
+trusted_audiences_test() ->
+ ClientContext =
+ #oidcc_client_context{
+ client_id = ClientId,
+ jwks = Jwk,
+ provider_configuration = #oidcc_provider_configuration{issuer = Issuer}
+ } = client_context_fixture(),
+
+ ExtraAudience = <<"audience_member">>,
+ LocalEndpoint = <<"https://my.server/auth">>,
+ AuthCode = <<"1234567890">>,
+ AccessToken = <<"access_token">>,
+ Claims =
+ #{
+ <<"iss">> => Issuer,
+ <<"sub">> => <<"sub">>,
+ <<"aud">> => [ClientId, ExtraAudience],
+ <<"azp">> => ClientId,
+ <<"iat">> => erlang:system_time(second),
+ <<"exp">> => erlang:system_time(second) + 10
+ },
+
+ 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">>
+ }),
+
+ ok = meck:new(httpc, [no_link]),
+ HttpFun =
+ fun(
+ post,
+ {_TokenEndpoint, _Header, "application/x-www-form-urlencoded", _Body},
+ _HttpOpts,
+ _Opts
+ ) ->
+ {ok, {{"HTTP/1.1", 200, "OK"}, [{"content-type", "application/json"}], TokenData}}
+ end,
+ ok = meck:expect(httpc, request, HttpFun),
+
+ ?assertMatch(
+ {ok, #oidcc_token{}},
+ oidcc_token:retrieve(
+ AuthCode,
+ ClientContext,
+ #{redirect_uri => LocalEndpoint}
+ )
+ ),
+
+ ?assertMatch(
+ {ok, #oidcc_token{}},
+ oidcc_token:retrieve(
+ AuthCode,
+ ClientContext,
+ #{redirect_uri => LocalEndpoint, trusted_audiences => any}
+ )
+ ),
+
+ ?assertMatch(
+ {ok, #oidcc_token{}},
+ oidcc_token:retrieve(
+ AuthCode,
+ ClientContext,
+ #{redirect_uri => LocalEndpoint, trusted_audiences => [ExtraAudience]}
+ )
+ ),
+
+ ?assertMatch(
+ {error, {missing_claim, {<<"aud">>, ClientId}, Claims}},
+ oidcc_token:retrieve(
+ AuthCode,
+ ClientContext,
+ #{redirect_uri => LocalEndpoint, trusted_audiences => []}
+ )
+ ),
+
+ true = meck:validate(httpc),
+
+ meck:unload(httpc),
+
+ ok.
+
+retrieve_pkce_required_test() ->
+ ClientContext = client_context_fixture(),
+ RedirectUri = <<"https://redirect.example/">>,
+
+ ?assertEqual(
+ {error, pkce_verifier_required},
+ oidcc_token:retrieve(<<"code">>, ClientContext, #{
+ redirect_uri => RedirectUri,
+ require_pkce => true
+ })
+ ),
+
+ ok.
+
+client_context_fixture() ->
+ PrivDir = code:priv_dir(oidcc),
+
+ {ok, ConfigurationBinary} = file:read_file(PrivDir ++ "/test/fixtures/example-metadata.json"),
+ {ok, Configuration} = oidcc_provider_configuration:decode_configuration(
+ jose:decode(ConfigurationBinary)
+ ),
+
+ 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">>}
+ },
+
+ ClientId = <<"client_id">>,
+ ClientSecret = <<"client_secret">>,
+
+ oidcc_client_context:from_manual(Configuration, Jwk, ClientId, ClientSecret, #{
+ client_jwks => ClientJwk
+ }).