diff --git a/.credo.exs b/.credo.exs index 6dc7b790..77b0fffc 100644 --- a/.credo.exs +++ b/.credo.exs @@ -100,6 +100,7 @@ # TODO: enable by default in Credo 1.1 {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, false}, # ## Refactoring Opportunities diff --git a/lib/boruta/adapters/codes.ex b/lib/boruta/adapters/codes.ex index d216ed60..dd7a0f5f 100644 --- a/lib/boruta/adapters/codes.ex +++ b/lib/boruta/adapters/codes.ex @@ -11,4 +11,6 @@ defmodule Boruta.CodesAdapter do def create(params), do: codes().create(params) def revoke(code), do: codes().revoke(code) def revoke_previous_token(code), do: codes().revoke_previous_token(code) + def update_sub(code, sub, metadata_policy), do: codes().update_sub(code, sub, metadata_policy) + def code_chain(code), do: codes().code_chain(code) end diff --git a/lib/boruta/adapters/ecto/codes.ex b/lib/boruta/adapters/ecto/codes.ex index 06c281e4..177d23b6 100644 --- a/lib/boruta/adapters/ecto/codes.ex +++ b/lib/boruta/adapters/ecto/codes.ex @@ -39,10 +39,12 @@ defmodule Boruta.Ecto.Codes do def get_by(id: id) do with {:ok, id} <- Ecto.UUID.cast(id), - {:ok, token} <- TokenStore.get(id: id) do - token + {:ok, token} <- TokenStore.get(id: id) do + token else - :error -> nil + :error -> + nil + {:error, "Not cached."} -> with %Token{} = token <- repo().one( @@ -64,7 +66,8 @@ defmodule Boruta.Ecto.Codes do token {:error, "Not cached."} -> - with %Token{} = token <- + with "" <> value <- value, + %Token{} = token <- repo().one( from t in Token, where: t.type in ["code", "preauthorized_code"] and t.value == ^value @@ -74,6 +77,9 @@ defmodule Boruta.Ecto.Codes do |> to_oauth_schema() |> TokenStore.put() do token + else + {:error, error} -> {:error, error} + nil -> {:error, "Code not found."} end end end @@ -100,6 +106,7 @@ defmodule Boruta.Ecto.Codes do apply(Token, changeset_method(client), [ %Token{resource_owner: params[:resource_owner]}, %{ + response_type: params[:response_type], client_id: client_id, sub: sub, redirect_uri: redirect_uri, @@ -111,7 +118,8 @@ defmodule Boruta.Ecto.Codes do code_challenge_method: code_challenge_method, authorization_details: authorization_details, presentation_definition: params[:presentation_definition], - public_client_id: params[:public_client_id] + public_client_id: params[:public_client_id], + previous_code: params[:previous_code] } ]) @@ -130,6 +138,30 @@ defmodule Boruta.Ecto.Codes do defp changeset_method(%Oauth.Client{pkce: true}), do: :pkce_code_changeset @impl Boruta.Oauth.Codes + def revoke(codes) when is_list(codes) do + code_count = Enum.count(codes) + code_ids = Enum.map(codes, fn code -> code.id end) + now = DateTime.utc_now() + + with {^code_count, _} <- + from(t in Token, where: t.id in ^code_ids) + |> repo().update_all(set: [revoked_at: now]), + :ok <- + Enum.reduce(codes, :ok, fn code, acc -> + case TokenStore.invalidate(code) do + {:ok, _token} -> + acc + + error -> + error + end + end) do + {:ok, Enum.map(codes, fn code -> %{code | revoked_at: now} end)} + else + _ -> {:error, "Could not revoke code chain."} + end + end + def revoke(%Oauth.Token{value: value} = code) do with %Token{} = token <- repo().get_by(Token, value: value), {:ok, token} <- @@ -155,4 +187,49 @@ defmodule Boruta.Ecto.Codes do {:ok, code} end end + + @impl Boruta.Oauth.Codes + def update_sub(%Oauth.Token{id: id}, sub, metadata_policy) do + with %Token{} = code <- + repo().one( + from t in Token, + where: t.type in ["code", "preauthorized_code"] and t.id == ^id + ), + {:ok, code} <- Token.sub_changeset(code, sub, metadata_policy) |> repo().update(), + {:ok, code} <- TokenStore.invalidate(code) do + {:ok, to_oauth_schema(code)} + else + _ -> + {:error, "Preauthorized code not found."} + end + end + + @impl Boruta.Oauth.Codes + def code_chain(token, acc \\ []) + + def code_chain(%Oauth.Token{previous_code: nil} = code, acc) do + Enum.reject([code | acc], &is_nil/1) |> Enum.reverse() + end + + def code_chain(%Oauth.Token{type: "preauthorized_code", previous_code: value} = code, acc) do + case code_chain(get_by(value: value)) do + chain when is_list(chain) -> + [code | acc ++ chain] + + _ -> + acc + end + end + + def code_chain(%Oauth.Token{type: "code", previous_code: value} = code, acc) do + case code_chain(get_by(value: value)) do + chain when is_list(chain) -> + [code | acc ++ chain] + + _ -> + acc + end + end + + def code_chain(nil, _acc), do: {:error, "Previous code not found."} end diff --git a/lib/boruta/adapters/ecto/preauthorized_codes.ex b/lib/boruta/adapters/ecto/preauthorized_codes.ex index cbd8c725..a1fd82ff 100644 --- a/lib/boruta/adapters/ecto/preauthorized_codes.ex +++ b/lib/boruta/adapters/ecto/preauthorized_codes.ex @@ -18,7 +18,6 @@ defmodule Boruta.Ecto.PreauthorizedCodes do id: client_id, authorization_code_ttl: authorization_code_ttl } = client, - resource_owner: resource_owner, scope: scope, state: state, redirect_uri: redirect_uri @@ -29,17 +28,23 @@ defmodule Boruta.Ecto.PreauthorizedCodes do # TODO store resource owner credentials changeset = apply(Token, changeset_method(client), [ - %Token{resource_owner: resource_owner}, + %Token{resource_owner: params[:resource_owner]}, %{ + agent_token: params[:agent_token], + authorization_code_ttl: authorization_code_ttl, + authorization_details: params[:authorization_details], client_id: client_id, - sub: sub, - state: state, + code_challenge: params[:code_challenge], + code_challenge_method: params[:code_challenge_method], nonce: params[:nonce], - agent_token: params[:agent_token], - scope: scope, + presentation_definition: params[:presentation_definition], + previous_code: params[:previous_code], + public_client_id: params[:public_client_id], redirect_uri: redirect_uri, - authorization_code_ttl: authorization_code_ttl, - authorization_details: resource_owner.authorization_details + response_type: params[:response_type], + scope: scope, + state: state, + sub: sub } ]) diff --git a/lib/boruta/adapters/ecto/schemas/token.ex b/lib/boruta/adapters/ecto/schemas/token.ex index 4ab5f64f..0ce4ce69 100644 --- a/lib/boruta/adapters/ecto/schemas/token.ex +++ b/lib/boruta/adapters/ecto/schemas/token.ex @@ -18,6 +18,7 @@ defmodule Boruta.Ecto.Token do @type t :: %__MODULE__{ type: String.t(), value: String.t(), + response_type: String.t() | nil, tx_code: String.t() | nil, authorization_details: list(), state: String.t(), @@ -60,6 +61,7 @@ defmodule Boruta.Ecto.Token do schema "oauth_tokens" do field(:type, :string) field(:value, :string) + field(:response_type, :string) field(:authorization_details, {:array, :map}, default: []) field(:presentation_definition, :map) field(:refresh_token, :string) @@ -83,6 +85,7 @@ defmodule Boruta.Ecto.Token do field(:agent_token, :string) field(:bind_data, :map) field(:bind_configuration, :map) + field(:metadata_policy, :map) field(:resource_owner, :map, virtual: true) @@ -198,17 +201,21 @@ defmodule Boruta.Ecto.Token do def preauthorized_code_changeset(token, attrs) do token |> cast(attrs, [ + :response_type, + :agent_token, :authorization_code_ttl, + :authorization_details, :client_id, - :sub, - :state, :nonce, - :scope, - :authorization_details, + :presentation_definition, + :previous_code, + :public_client_id, :redirect_uri, - :agent_token + :scope, + :state, + :sub ]) - |> validate_required([:authorization_code_ttl, :client_id, :sub]) + |> validate_required([:authorization_code_ttl, :client_id]) |> foreign_key_constraint(:client_id) |> put_change(:type, "preauthorized_code") |> put_value() @@ -220,22 +227,25 @@ defmodule Boruta.Ecto.Token do def pkce_preauthorized_code_changeset(token, attrs) do token |> cast(attrs, [ + :response_type, + :agent_token, :authorization_code_ttl, + :authorization_details, :client_id, - :sub, - :state, - :nonce, - :scope, :code_challenge, :code_challenge_method, - :authorization_details, + :nonce, + :presentation_definition, + :previous_code, + :public_client_id, :redirect_uri, - :agent_token + :scope, + :state, + :sub ]) |> validate_required([ :authorization_code_ttl, :client_id, - :sub, :code_challenge ]) |> foreign_key_constraint(:client_id) @@ -251,6 +261,7 @@ defmodule Boruta.Ecto.Token do def code_changeset(token, attrs) do token |> cast(attrs, [ + :response_type, :authorization_code_ttl, :client_id, :public_client_id, @@ -260,7 +271,8 @@ defmodule Boruta.Ecto.Token do :nonce, :scope, :authorization_details, - :presentation_definition + :presentation_definition, + :previous_code ]) |> validate_required([:authorization_code_ttl, :client_id, :sub, :redirect_uri]) |> foreign_key_constraint(:client_id) @@ -273,6 +285,7 @@ defmodule Boruta.Ecto.Token do def pkce_code_changeset(token, attrs) do token |> cast(attrs, [ + :response_type, :authorization_code_ttl, :client_id, :public_client_id, @@ -284,7 +297,8 @@ defmodule Boruta.Ecto.Token do :code_challenge, :code_challenge_method, :authorization_details, - :presentation_definition + :presentation_definition, + :previous_code ]) |> validate_required([ :authorization_code_ttl, @@ -302,6 +316,11 @@ defmodule Boruta.Ecto.Token do |> encrypt_code_challenge() end + @doc false + def sub_changeset(code, sub, metadata_policy) do + change(code, %{sub: sub, type: "code", metadata_policy: metadata_policy}) + end + @doc false def revoke_refresh_token_changeset(token) do now = DateTime.utc_now() diff --git a/lib/boruta/adapters/ecto/stores/token_store.ex b/lib/boruta/adapters/ecto/stores/token_store.ex index 221bd27b..180e1936 100644 --- a/lib/boruta/adapters/ecto/stores/token_store.ex +++ b/lib/boruta/adapters/ecto/stores/token_store.ex @@ -63,7 +63,7 @@ defmodule Boruta.Ecto.TokenStore do end @spec invalidate(token :: Boruta.Oauth.Token.t()) :: - {:ok, token :: Boruta.Oauth.Token.t()} + {:ok, token :: Boruta.Oauth.Token.t()} | {:error, term()} def invalidate(token) do with :ok <- cache_backend().delete({Token, :value, token.value}), :ok <- cache_backend().delete({Token, :refresh_token, token.refresh_token}) do diff --git a/lib/boruta/oauth/authorization.ex b/lib/boruta/oauth/authorization.ex index 9346f873..363208a4 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -383,7 +383,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AgentCodeRequest do nonce: code.nonce, authorization_details: code.authorization_details, bind_data: bind_data, - bind_configuration: bind_configuration, + bind_configuration: bind_configuration }} end end @@ -399,7 +399,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AgentCodeRequest do nonce: nonce, authorization_details: authorization_details, bind_data: bind_data, - bind_configuration: bind_configuration, + bind_configuration: bind_configuration }} <- preauthorize(request), {:ok, agent_token} <- @@ -458,11 +458,12 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizationCodeReques :ok <- maybe_check_tx_code(tx_code, code), {:ok, %ResourceOwner{sub: sub}} <- (case code.agent_token do - nil -> - Authorization.ResourceOwner.authorize(resource_owner: code.resource_owner) - _ -> - {:ok, code.resource_owner} - end) do + nil -> + Authorization.ResourceOwner.authorize(resource_owner: code.resource_owner) + + _ -> + {:ok, code.resource_owner} + end) do {:ok, %AuthorizationSuccess{ client: code.client, @@ -652,6 +653,8 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.TokenRequest do end defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizedCodeRequest do + alias Boruta.CodesAdapter + alias Boruta.ClientsAdapter alias Boruta.PreauthorizedCodesAdapter alias Boruta.Oauth.Authorization alias Boruta.Oauth.AuthorizationSuccess @@ -667,28 +670,34 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizedCodeRequest d client_id: client_id, redirect_uri: redirect_uri, resource_owner: resource_owner, + code: previous_code, state: state, scope: scope, grant_type: grant_type }) do with {:ok, client} <- - Authorization.Client.authorize( - id: client_id, - source: nil, - redirect_uri: redirect_uri, - grant_type: grant_type - ), - {:ok, %ResourceOwner{sub: sub} = resource_owner} <- - (case agent_token do - nil -> - Authorization.ResourceOwner.authorize(resource_owner: resource_owner) + (case client_id do + "did:" <> _key -> + {:ok, ClientsAdapter.public!()} - agent_token -> - Authorization.AgentToken.authorize( - agent_token: agent_token, - resource_owner: resource_owner + _ -> + Authorization.Client.authorize( + id: client_id, + source: nil, + redirect_uri: redirect_uri, + grant_type: grant_type ) end), + {:ok, code} <- + (case previous_code do + nil -> {:ok, nil} + previous_code -> Authorization.Code.authorize(%{value: previous_code}) + end), + {:ok, %ResourceOwner{sub: sub} = resource_owner} <- + Authorization.AgentToken.authorize( + agent_token: (code && code.agent_token) || agent_token, + resource_owner: resource_owner + ), {:ok, scope} <- Authorization.Scope.authorize( scope: scope, @@ -698,11 +707,13 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizedCodeRequest d %AuthorizationSuccess{ client: client, redirect_uri: redirect_uri, + code: code, sub: sub, scope: scope, state: state, resource_owner: resource_owner, - agent_token: agent_token + agent_token: agent_token, + authorization_details: resource_owner.authorization_details }} else {:error, :invalid_code_challenge} -> @@ -724,24 +735,29 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizedCodeRequest d client: client, resource_owner: resource_owner, redirect_uri: redirect_uri, + code: code, sub: sub, scope: scope, state: state, nonce: nonce, - agent_token: agent_token + agent_token: agent_token, + authorization_details: authorization_details }} <- preauthorize(request) do # TODO create a preauthorized code with {:ok, preauthorized_code} <- PreauthorizedCodesAdapter.create(%{ + public_client_id: code && code.sub, client: client, resource_owner: resource_owner, redirect_uri: redirect_uri, + previous_code: code && code.value, sub: sub, scope: scope, state: state, nonce: nonce, - agent_token: agent_token + agent_token: agent_token, + authorization_details: authorization_details }) do {:ok, %{preauthorized_code: preauthorized_code}} end @@ -835,6 +851,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.CodeRequest do preauthorize(request) do with {:ok, code} <- CodesAdapter.create(%{ + response_type: "code", client: client, resource_owner: resource_owner, redirect_uri: redirect_uri, @@ -946,7 +963,7 @@ end defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do alias Boruta.ClientsAdapter - alias Boruta.CodesAdapter + alias Boruta.PreauthorizedCodesAdapter alias Boruta.Oauth.Authorization alias Boruta.Oauth.AuthorizationSuccess alias Boruta.Oauth.CodeRequest @@ -958,20 +975,21 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do def preauthorize( %PresentationRequest{ + authorization_details: authorization_details, client_id: client_id, - resource_owner: resource_owner, - redirect_uri: redirect_uri, - state: state, - nonce: nonce, - scope: scope, + client_metadata: client_metadata, + code: code, code_challenge: code_challenge, code_challenge_method: code_challenge_method, - authorization_details: authorization_details, - client_metadata: client_metadata, - response_type: response_type + nonce: nonce, + redirect_uri: redirect_uri, + resource_owner: resource_owner, + response_type: response_type, + scope: scope, + state: state } = request ) do - with [response_type] = response_types <- + with response_types = response_types <- VerifiablePresentations.response_types( response_type, scope, @@ -980,16 +998,24 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do {:ok, client} <- (case client_id do "did:" <> _key -> - {:ok, ClientsAdapter.public!()} + {:ok, ClientsAdapter.public!()} _ -> Authorization.Client.authorize( id: client_id, source: nil, redirect_uri: redirect_uri, - grant_type: response_type + grant_type: List.first(response_types) ) end), + {:ok, _code} <- + (case code do + nil -> + {:ok, nil} + + code -> + Authorization.Code.authorize(%{value: code}) + end), :ok <- Authorization.Nonce.authorize(request), :ok <- VerifiableCredentials.validate_authorization_details(authorization_details), :ok <- VerifiablePresentations.check_client_metadata(client_metadata), @@ -998,27 +1024,27 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do resource_owner.presentation_configuration, scope ) do - - {code_challenge, code_challenge_method} = case resource_owner.code_verifier do - nil -> {code_challenge, code_challenge_method} - code_verifier -> {code_verifier , "plain"} - end + {code_challenge, code_challenge_method} = + case resource_owner.code_verifier do + nil -> {code_challenge, code_challenge_method} + code_verifier -> {code_verifier, "plain"} + end {:ok, %AuthorizationSuccess{ - response_types: response_types, - presentation_definition: presentation_definition, - redirect_uri: redirect_uri, - public_client_id: client_id, + authorization_details: Jason.decode!(authorization_details), client: client, - sub: resource_owner.sub, - scope: scope, - state: state, - nonce: nonce, + code: code, code_challenge: code_challenge, code_challenge_method: code_challenge_method, - authorization_details: Jason.decode!(authorization_details), - response_mode: client.response_mode + nonce: nonce, + presentation_definition: presentation_definition, + public_client_id: client_id, + redirect_uri: redirect_uri, + response_mode: client.response_mode, + response_types: response_types, + scope: scope, + state: state }} else {:error, :invalid_code_challenge} -> @@ -1037,27 +1063,28 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do def token(request) do with {:ok, %AuthorizationSuccess{ - response_types: response_types, - presentation_definition: presentation_definition, - redirect_uri: redirect_uri, - public_client_id: public_client_id, + authorization_details: authorization_details, client: client, - sub: sub, - scope: scope, - state: state, - nonce: nonce, + code: code, code_challenge: code_challenge, code_challenge_method: code_challenge_method, - authorization_details: authorization_details, - response_mode: response_mode + nonce: nonce, + presentation_definition: presentation_definition, + public_client_id: public_client_id, + redirect_uri: redirect_uri, + response_mode: response_mode, + response_types: response_types, + scope: scope, + state: state }} <- preauthorize(request) do with {:ok, code} <- - CodesAdapter.create(%{ + PreauthorizedCodesAdapter.create(%{ + response_type: Enum.join(response_types, " "), client: client, public_client_id: public_client_id, redirect_uri: redirect_uri, - sub: sub, + previous_code: code, scope: scope, state: state, nonce: nonce, @@ -1066,11 +1093,11 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do authorization_details: authorization_details, presentation_definition: presentation_definition }) do - case response_types do - ["id_token"] -> + case List.first(response_types) do + "id_token" -> {:ok, %{siopv2_code: code, response_mode: response_mode}} - ["vp_token"] -> + "vp_token" -> {:ok, %{vp_code: code, response_mode: response_mode}} end end @@ -1119,6 +1146,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.HybridRequest do "code", {:ok, tokens} when tokens == %{} -> with {:ok, code} <- CodesAdapter.create(%{ + response_type: Enum.join(response_types, " "), client: client, resource_owner: resource_owner, redirect_uri: redirect_uri, diff --git a/lib/boruta/oauth/authorization/agent_token.ex b/lib/boruta/oauth/authorization/agent_token.ex index 57b99e9f..4f016f1e 100644 --- a/lib/boruta/oauth/authorization/agent_token.ex +++ b/lib/boruta/oauth/authorization/agent_token.ex @@ -4,9 +4,14 @@ defmodule Boruta.Oauth.Authorization.AgentToken do """ alias Boruta.AgentTokensAdapter + alias Boruta.Oauth.Authorization alias Boruta.Oauth.Error alias Boruta.Oauth.Token + def authorize(agent_token: nil, resource_owner: resource_owner) do + Authorization.ResourceOwner.authorize(resource_owner: resource_owner) + end + def authorize(agent_token: value, resource_owner: resource_owner) do with %Token{} = agent_token <- AgentTokensAdapter.get_by(value: value), {:ok, claims} <- AgentTokensAdapter.claims_from_agent_token(agent_token) do diff --git a/lib/boruta/oauth/contexts/codes.ex b/lib/boruta/oauth/contexts/codes.ex index d7870ae9..0c976bd4 100644 --- a/lib/boruta/oauth/contexts/codes.ex +++ b/lib/boruta/oauth/contexts/codes.ex @@ -6,43 +6,54 @@ defmodule Boruta.Oauth.Codes do @doc """ Returns a `Boruta.Oauth.Token` by `value` and `redirect_uri`. """ - @callback get_by( - params :: [id: String.t()] - ) :: token :: Boruta.Oauth.Token | nil - @callback get_by( - params :: [value: String.t()] - ) :: token :: Boruta.Oauth.Token | nil - @callback get_by( - params :: [value: String.t(), redirect_uri: String.t()] - ) :: token :: Boruta.Oauth.Token | nil + @callback get_by(params :: [id: String.t()]) :: token :: Boruta.Oauth.Token.t() | nil + @callback get_by(params :: [value: String.t()]) :: token :: Boruta.Oauth.Token.t() | nil + @callback get_by(params :: [value: String.t(), redirect_uri: String.t()]) :: + token :: Boruta.Oauth.Token.t() | nil @doc """ Persists a token according to given params. """ - @callback create(params :: %{ - :client => Boruta.Oauth.Client.t(), - :sub => String.t(), - :redirect_uri => String.t(), - :scope => String.t(), - :state => String.t(), - :code_challenge => String.t(), - :code_challenge_method => String.t(), - :authorization_details => list(map()) | nil, - :presentation_definition => map() | nil, - optional(:resource_owner) => Boruta.Oauth.ResourceOwner.t() - }) :: code :: Boruta.Oauth.Token.t() | {:error, reason :: term()} + @callback create( + params :: %{ + :client => Boruta.Oauth.Client.t(), + :sub => String.t(), + :redirect_uri => String.t(), + :scope => String.t(), + :state => String.t(), + :code_challenge => String.t(), + :code_challenge_method => String.t(), + :authorization_details => list(map()) | nil, + :presentation_definition => map() | nil, + optional(:resource_owner) => Boruta.Oauth.ResourceOwner.t() + } + ) :: code :: Boruta.Oauth.Token.t() | {:error, reason :: term()} @doc """ Revokes the given `Boruta.Oauth.Token` code. """ - @callback revoke( - token :: Boruta.Oauth.Token.t() - ) :: {:ok, Boruta.Oauth.Token.t()} | {:error, reason :: term()} + @callback revoke(Boruta.Oauth.Token.t() | list(Boruta.Oauth.Token.t())) :: + {:ok, Boruta.Oauth.Token.t()} | {:error, reason :: term()} @doc """ - Revokes the the previouly issued token given `Boruta.Oauth.Token` code. + Revokes the the previouly issued token given a `Boruta.Oauth.Token` code. """ - @callback revoke_previous_token( - token :: Boruta.Oauth.Token.t() - ) :: {:ok, Boruta.Oauth.Token.t()} | {:error, reason :: term()} + @callback revoke_previous_token(token :: Boruta.Oauth.Token.t()) :: + {:ok, Boruta.Oauth.Token.t()} | {:error, reason :: term()} + + @doc """ + Updates given `Boruta.Oauth.Token` code sub value. The resulting token is of type "code". + """ + @callback update_sub( + preauthorized_code :: Boruta.Oauth.Token.t(), + sub :: String.t(), + metadata_policy :: map() + ) :: + {:ok, preauthorized_code :: Boruta.Oauth.Token.t()} | {:error, reason :: term()} + + @doc """ + Returns the code chain previously issued given a `Boruta.Oauth.Token` code. + """ + @callback code_chain(token :: Boruta.Oauth.Token.t()) :: + list(token :: Boruta.Oauth.Token.t()) | {:error, reason :: String.t()} end diff --git a/lib/boruta/oauth/contexts/preauthorized_codes.ex b/lib/boruta/oauth/contexts/preauthorized_codes.ex index 6b3b06cd..75d092ec 100644 --- a/lib/boruta/oauth/contexts/preauthorized_codes.ex +++ b/lib/boruta/oauth/contexts/preauthorized_codes.ex @@ -3,13 +3,15 @@ defmodule Boruta.Openid.PreauthorizedCodes do Preauthorized code context """ - @callback create(params :: %{ - :client => Boruta.Oauth.Client.t(), - :sub => String.t(), - :redirect_uri => String.t(), - :scope => String.t(), - :state => String.t(), - :resource_owner => Boruta.Oauth.ResourceOwner.t(), - :agent_token => String.t() | nil - }) :: preauthorized_code :: Boruta.Oauth.Token.t() | {:error, reason :: term()} + @callback create( + params :: %{ + :client => Boruta.Oauth.Client.t(), + :sub => String.t(), + :redirect_uri => String.t(), + :scope => String.t(), + :state => String.t(), + :resource_owner => Boruta.Oauth.ResourceOwner.t(), + :agent_token => String.t() | nil + } + ) :: preauthorized_code :: Boruta.Oauth.Token.t() | {:error, reason :: term()} end diff --git a/lib/boruta/oauth/error.ex b/lib/boruta/oauth/error.ex index f0096311..286d468c 100644 --- a/lib/boruta/oauth/error.ex +++ b/lib/boruta/oauth/error.ex @@ -134,7 +134,7 @@ defmodule Boruta.Oauth.Error do redirect_uri: redirect_uri, state: state }) do - %{error | format: :fragment, redirect_uri: redirect_uri, state: state} + %{error | format: :query, redirect_uri: redirect_uri, state: state} end defp response_mode(%HybridRequest{response_mode: "query"}), do: :query diff --git a/lib/boruta/oauth/json/schema.ex b/lib/boruta/oauth/json/schema.ex index a1cfce3e..52d09329 100644 --- a/lib/boruta/oauth/json/schema.ex +++ b/lib/boruta/oauth/json/schema.ex @@ -226,10 +226,7 @@ defmodule Boruta.Oauth.Json.Schema do "type" => "object", "properties" => %{ "response_type" => %{"type" => "string", "pattern" => "urn:ietf:params:oauth:response-type:pre-authorized_code"}, - "client_id" => %{ - "type" => "string", - "pattern" => @uuid_pattern - }, + "client_id" => %{"type" => "string"}, "state" => %{"type" => "string"}, "nonce" => %{"type" => "string"}, "redirect_uri" => %{"type" => "string"}, diff --git a/lib/boruta/oauth/request/base.ex b/lib/boruta/oauth/request/base.ex index 67a4f650..e0283fa7 100644 --- a/lib/boruta/oauth/request/base.ex +++ b/lib/boruta/oauth/request/base.ex @@ -106,6 +106,7 @@ defmodule Boruta.Oauth.Request.Base do {:ok, %PreauthorizedCodeRequest{ agent_token: params["agent_token"], + code: params["code"], client_id: params["client_id"], redirect_uri: params["redirect_uri"], resource_owner: params["resource_owner"], @@ -125,7 +126,10 @@ defmodule Boruta.Oauth.Request.Base do }} end - def build_request(%{"response_type" => response_type, "client_metadata" => client_metadata} = params) when response_type in ["code", "vp_token"] do + def build_request( + %{"response_type" => response_type, "client_metadata" => client_metadata} = params + ) + when response_type in ["code", "id_token", "id_token vp_token", "id_token urn:ietf:params:oauth:response-type:pre-authorized_code", "vp_token"] do request = %PresentationRequest{ client_id: params["client_id"], resource_owner: params["resource_owner"], @@ -135,6 +139,7 @@ defmodule Boruta.Oauth.Request.Base do prompt: params["prompt"], code_challenge: params["code_challenge"], code_challenge_method: params["code_challenge_method"], + code: params["code"], scope: params["scope"], client_metadata: client_metadata, response_type: params["response_type"] @@ -391,6 +396,7 @@ defmodule Boruta.Oauth.Request.Base do else {:ok, _payload} -> {:error, "Either alg header missing or cnf claim missing in client assertion."} + _ -> {:error, "Could not decode client assertion JWT."} end diff --git a/lib/boruta/oauth/requests/preauthorized_code.ex b/lib/boruta/oauth/requests/preauthorized_code.ex index eac0895e..43a185e6 100644 --- a/lib/boruta/oauth/requests/preauthorized_code.ex +++ b/lib/boruta/oauth/requests/preauthorized_code.ex @@ -10,6 +10,7 @@ defmodule Boruta.Oauth.PreauthorizedCodeRequest do """ @type t :: %__MODULE__{ agent_token: String.t() | nil, + code: String.t() | nil, client_id: String.t(), redirect_uri: String.t(), state: String.t(), @@ -22,6 +23,7 @@ defmodule Boruta.Oauth.PreauthorizedCodeRequest do @enforce_keys [:client_id, :redirect_uri, :resource_owner] defstruct agent_token: nil, + code: nil, client_id: nil, redirect_uri: nil, state: "", diff --git a/lib/boruta/oauth/requests/presentation_request.ex b/lib/boruta/oauth/requests/presentation_request.ex index 513ce7e4..bcd0c605 100644 --- a/lib/boruta/oauth/requests/presentation_request.ex +++ b/lib/boruta/oauth/requests/presentation_request.ex @@ -8,6 +8,7 @@ defmodule Boruta.Oauth.PresentationRequest do """ @type t :: %__MODULE__{ client_id: String.t(), + code: String.t() | nil, resource_owner: Boruta.Oauth.ResourceOwner.t(), redirect_uri: String.t(), state: String.t(), @@ -24,6 +25,7 @@ defmodule Boruta.Oauth.PresentationRequest do @enforce_keys [:client_id, :redirect_uri] defstruct client_id: nil, + code: nil, resource_owner: nil, redirect_uri: nil, state: "", diff --git a/lib/boruta/oauth/schemas/client.ex b/lib/boruta/oauth/schemas/client.ex index ecb64a8c..f15f05f7 100644 --- a/lib/boruta/oauth/schemas/client.ex +++ b/lib/boruta/oauth/schemas/client.ex @@ -46,7 +46,8 @@ defmodule Boruta.Oauth.Client do logo_uri: nil, response_mode: nil, metadata: %{}, - signatures_adapter: nil + signatures_adapter: nil, + metadata_policies: [] @type t :: %__MODULE__{ id: any(), @@ -83,12 +84,14 @@ defmodule Boruta.Oauth.Client do logo_uri: String.t() | nil, response_mode: String.t(), metadata: map(), - signatures_adapter: String.t() + signatures_adapter: String.t(), + metadata_policies: list(map()) } @wallet_grant_types [ "id_token", "vp_token", + "preauthorized_code", "authorization_code", "agent_credentials" ] @@ -99,7 +102,6 @@ defmodule Boruta.Oauth.Client do "password", "authorization_code", "agent_code", - "preauthorized_code", "refresh_token", "implicit", "revoke", diff --git a/lib/boruta/oauth/schemas/scope/authorize.ex b/lib/boruta/oauth/schemas/scope/authorize.ex index 08b1876b..8de9ad44 100644 --- a/lib/boruta/oauth/schemas/scope/authorize.ex +++ b/lib/boruta/oauth/schemas/scope/authorize.ex @@ -19,6 +19,10 @@ defimpl Boruta.Oauth.Scope.Authorize, for: Boruta.Oauth.ResourceOwner do alias Boruta.Oauth.ResourceOwner + def authorized_scopes(%ResourceOwner{sub: "did:" <> _key}, scopes, public_scopes) do + scopes -- (scopes -- public_scopes) # intersection + end + def authorized_scopes(%ResourceOwner{} = resource_owner, scopes, _public_scopes) do authorized_scopes = Enum.map(resource_owners().authorized_scopes(resource_owner), fn e -> e.name end) diff --git a/lib/boruta/oauth/schemas/token.ex b/lib/boruta/oauth/schemas/token.ex index dad0c4f8..9767de59 100644 --- a/lib/boruta/oauth/schemas/token.ex +++ b/lib/boruta/oauth/schemas/token.ex @@ -14,6 +14,7 @@ defmodule Boruta.Oauth.Token do @enforce_keys [:type] defstruct id: nil, type: nil, + response_type: nil, value: nil, tx_code: nil, authorization_details: nil, @@ -38,12 +39,14 @@ defmodule Boruta.Oauth.Token do previous_code: nil, bind_data: nil, bind_configuration: nil, - agent_token: nil + agent_token: nil, + metadata_policy: %{} # TODO manage nil attribute values and watch for aftereffects of them @type t :: %__MODULE__{ id: String.t(), type: String.t(), + response_type: String.t() | nil, value: String.t() | nil, tx_code: String.t() | nil, authorization_details: list() | nil, @@ -68,7 +71,8 @@ defmodule Boruta.Oauth.Token do previous_code: String.t() | nil, bind_data: String.t() | nil, bind_configuration: String.t() | nil, - agent_token: String.t() | nil + agent_token: String.t() | nil, + metadata_policy: map() } @doc """ diff --git a/lib/boruta/oauth/validator.ex b/lib/boruta/oauth/validator.ex index 988557d7..beb46d99 100644 --- a/lib/boruta/oauth/validator.ex +++ b/lib/boruta/oauth/validator.ex @@ -72,6 +72,8 @@ defmodule Boruta.Oauth.Validator do "vp_token", "id_token", "id_token token", + "id_token urn:ietf:params:oauth:response-type:pre-authorized_code", + "id_token vp_token", "code", "code id_token", "code token", @@ -102,7 +104,7 @@ defmodule Boruta.Oauth.Validator do def validate(:authorize, %{"response_type" => _}) do {:error, - "Invalid response_type param, may be one of `code` for Authorization Code request, `code id_token`, `code token`, `code id_token token` for Hybrid requests, or `token`, `id_token token` for Implicit requests."} + "Invalid response_type param."} end def validate(:introspect, params) do @@ -138,6 +140,8 @@ defmodule Boruta.Oauth.Validator do defp validate_multiple_response_types(%{"response_type" => response_types} = params) do response_types |> String.split(" ") + # TODO validate custom preauthorized code requests + |> Enum.reject(fn response_type -> response_type == "urn:ietf:params:oauth:response-type:pre-authorized_code" end) |> Enum.reduce_while(:ok, fn response_type, _acc -> case ExJsonSchema.Validator.validate( apply(Schema, String.to_atom(response_type), []), diff --git a/lib/boruta/openid.ex b/lib/boruta/openid.ex index c776cfac..d6e2192e 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -17,7 +17,9 @@ end defmodule Boruta.Openid do @moduledoc """ - Openid requests entrypoint, provides additional artifacts to OAuth Provided Openid Connect and Openid 4 verifiable credentials specifications + Openid requests entrypoint, provides additional artifacts to OAuth as stated in [Openid Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html), + [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) and + [OpenID for Verifiable Presentations](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) > __Note__: this module follows inverted hexagonal architecture, its functions will invoke callbacks of the given module argument and return its result. > @@ -74,13 +76,19 @@ defmodule Boruta.Openid do with {:ok, access_token} <- BearerToken.extract_token(conn), {:ok, token} <- AccessToken.authorize(value: access_token), {:ok, credential_params} <- validate_credential_params(credential_params), + %Token{} = code <- CodesAdapter.get_by(value: token.previous_code), + [_h | _t] = code_chain <- CodesAdapter.code_chain(code), + :ok <- + maybe_verify_public_client_id(credential_params, code_chain, token.client), + :ok <- check_client_metadata_policy(code_chain, credential_params), {:ok, credential} <- VerifiableCredentials.issue_verifiable_credential( token.resource_owner, credential_params, token, default_credential_configuration - ) do + ), + {:ok, _codes} <- maybe_revoke_code_chain(%{credential: credential}, code_chain) do case credential do %{defered: true} -> case CredentialsAdapter.create_credential(credential, token) do @@ -106,6 +114,15 @@ defmodule Boruta.Openid do {:error, %Error{} = error} -> module.credential_failure(conn, error) + nil -> + error = %Error{ + status: :bad_request, + error: :invalid_request, + error_description: "Previous code not found." + } + + module.credential_failure(conn, error) + {:error, reason} -> error = %Error{ status: :bad_request, @@ -143,7 +160,8 @@ defmodule Boruta.Openid do code_verifier: String.t() | nil, id_token: nil | String.t(), vp_token: nil | String.t(), - presentation_submission: nil | String.t() + presentation_submission: nil | String.t(), + metadata_policy: map() } @spec direct_post( conn :: Plug.Conn.t(), @@ -151,25 +169,43 @@ defmodule Boruta.Openid do module :: atom() ) :: any() def direct_post(conn, direct_post_params, module) do - with {:ok, _claims} <- check_id_token_client(direct_post_params), - %Token{value: value} = code <- CodesAdapter.get_by(id: direct_post_params[:code_id]) do - with {:ok, code} <- + with {:ok, kid, _claims} <- check_id_token_client(direct_post_params), + %Token{} = code <- CodesAdapter.get_by(id: direct_post_params[:code_id]) do + with {:ok, metadata_policy} <- Jason.decode(direct_post_params[:metadata_policy] || "{}"), + {:ok, %Token{value: value}} <- + CodesAdapter.update_sub(code, kid, metadata_policy), + {:ok, code} <- Authorization.Code.authorize(%{ value: value, code_verifier: direct_post_params[:code_verifier] }), + [_h | _t] = code_chain <- CodesAdapter.code_chain(code), :ok <- - maybe_check_public_client_id(direct_post_params, code.public_client_id, code.client), + maybe_verify_public_client_id(direct_post_params, code_chain, code.client), + :ok <- check_client_metadata_policy(code_chain, direct_post_params), :ok <- maybe_check_presentation(direct_post_params, code.presentation_definition), - {:ok, _code} <- CodesAdapter.revoke(code) do + {:ok, _codes} <- maybe_revoke_code_chain(direct_post_params, code_chain) do module.direct_post_success(conn, %DirectPostResponse{ id_token: direct_post_params[:id_token], vp_token: direct_post_params[:vp_token], code: code, + code_chain: code_chain, redirect_uri: code.redirect_uri, state: code.state }) else + {:continue, code_chain, error} -> + code = List.first(code_chain) + + module.direct_post_success(conn, %DirectPostResponse{ + id_token: direct_post_params[:id_token], + error: error, + code: code, + code_chain: code_chain, + redirect_uri: code.redirect_uri, + state: code.state + }) + {:error, "" <> error} -> module.authentication_failure(conn, %Error{ error: :unknown_error, @@ -180,13 +216,21 @@ defmodule Boruta.Openid do state: code.state }) - {:error, error} -> + {:error, %Error{} = error} -> module.authentication_failure(conn, %{ error | format: :query, redirect_uri: code.redirect_uri, state: code.state }) + + {:error, error} -> + module.authentication_failure(conn, %Error{ + error: :unknown_error, + status: :unprocessable_entity, + error_description: inspect(error), + format: :query + }) end else {:error, error} -> @@ -197,10 +241,78 @@ defmodule Boruta.Openid do end end + defp check_client_metadata_policy(code_chain, params) when is_list(code_chain) do + case code_chain + |> Enum.reverse() + |> Enum.reduce_while([], fn current, acc -> + acc = acc ++ [current] + + case do_check_client_metadata_policy( + params, + current.metadata_policy + ) do + :ok -> + {:cont, acc} + + {:error, error} -> + {:halt, + {:error, + %Error{ + status: :unauthorized, + error: :unauthorized, + error_description: error + }}} + end + end) do + {:error, error} -> + {:error, error} + + [_h | _t] -> + :ok + + [] -> + :ok + end + end + + defp do_check_client_metadata_policy([], _policy), do: :ok + + defp do_check_client_metadata_policy(%{"proof" => %{"proof_type" => "jwt", "jwt" => jwt}}, %{ + "client_id" => %{"one_of" => client_ids} + }) do + with {:ok, %{"kid" => kid}} <- Joken.peek_header(jwt), + true <- Enum.member?(client_ids, kid) do + :ok + else + _error -> + {:error, "Metadata policies check failed."} + end + end + + defp do_check_client_metadata_policy(%{id_token: _jwt}, _policy) do + :ok + # TODO continue in case invalid id_token + end + + defp do_check_client_metadata_policy(%{vp_token: jwt}, %{ + "client_id" => %{"one_of" => client_ids} + }) do + with {:ok, %{"kid" => kid}} <- Joken.peek_header(jwt), + true <- Enum.member?(client_ids, kid) do + :ok + else + _error -> + {:error, "Metadata policies check failed."} + end + end + + defp do_check_client_metadata_policy(_code, %{}), do: :ok + defp check_id_token_client(%{id_token: id_token}) do case VerifiableCredentials.validate_signature(id_token) do {:ok, _jwk, claims} -> - {:ok, claims} + {:ok, %{"kid" => kid}} = Joken.peek_header(id_token) + {:ok, kid, claims} {:error, error} -> {:error, @@ -215,7 +327,8 @@ defmodule Boruta.Openid do defp check_id_token_client(%{vp_token: vp_token}) do case VerifiablePresentations.validate_signature(vp_token) do {:ok, _jwk, claims} -> - {:ok, claims} + {:ok, %{"kid" => kid}} = Joken.peek_header(vp_token) + {:ok, kid, claims} {:error, error} -> {:error, @@ -236,52 +349,84 @@ defmodule Boruta.Openid do error_description: "id_token or vp_token param missing." }} - defp maybe_check_public_client_id(_direct_post_params, _public_client_id, %Client{ + defp maybe_verify_public_client_id(_direct_post_params, _code_chain, %Client{ check_public_client_id: false }), do: :ok - defp maybe_check_public_client_id( - %{id_token: id_token}, - "did:" <> _key = public_client_id, + defp maybe_verify_public_client_id( + %{vp_token: vp_token}, + [last | code_chain], _client ) do - with {:ok, %{"alg" => alg}} <- Joken.peek_header(id_token), - {:ok, _jwk, _claims} <- - VerifiablePresentations.verify_jwt({:did, public_client_id}, alg, id_token) do - :ok + with {:ok, %{"alg" => alg}} <- Joken.peek_header(vp_token) do + case VerifiablePresentations.verify_jwt({:did, last.public_client_id}, alg, vp_token) do + {:ok, _jwk, _claims} -> + check_public_client_id_in_chain(code_chain, last.public_client_id) + + _ -> + verify_token_against_chain(code_chain, vp_token, alg) + end else - {:error, _error} -> + false -> {:error, %Error{ status: :bad_request, error: :invalid_client, error_description: "Authorization client_id do not match vp_token signature." }} + + {:error, _error} -> + {:error, + %Error{ + status: :bad_request, + error: :invalid_request, + error_description: "VP token is invalid." + }} end end - defp maybe_check_public_client_id( - %{vp_token: vp_token}, - "did:" <> _key = public_client_id, + defp maybe_verify_public_client_id( + %{"proof" => %{"proof_type" => "jwt", "jwt" => jwt}}, + code_chain, _client ) do - with {:ok, %{"alg" => alg}} <- Joken.peek_header(vp_token), - {:ok, _jwk, _claims} <- - VerifiablePresentations.verify_jwt({:did, public_client_id}, alg, vp_token) do - :ok + with {:ok, %{"alg" => alg}} <- Joken.peek_header(jwt) do + verify_token_against_chain(code_chain, jwt, alg) else {:error, _error} -> {:error, %Error{ status: :bad_request, - error: :invalid_client, - error_description: "Authorization client_id do not match vp_token signature." + error: :invalid_request, + error_description: "VP token is invalid." }} end end - defp maybe_check_public_client_id(_direct_post_params, public_client_id, _client) do + defp maybe_verify_public_client_id( + %{id_token: _id_token}, + [ + %Token{ + public_client_id: "did:" <> _key + } + | _codes + ], + _client + ) do + :ok + end + + defp maybe_verify_public_client_id( + _direct_post_params, + [ + %Token{ + public_client_id: public_client_id + } + | _codes + ], + _client + ) do case public_client_id do "did:" <> _key -> {:error, @@ -290,11 +435,54 @@ defmodule Boruta.Openid do error: :invalid_client, error_description: "Authorization client_id do not match vp_token signature." }} + _client_id -> :ok end end + def check_public_client_id_in_chain(code_chain, public_client_id) do + case Enum.find(code_chain, fn + %Token{revoked_at: nil, sub: sub} -> sub == public_client_id + _ -> false + end) do + nil -> + {:error, + %Error{ + status: :bad_request, + error: :invalid_client, + error_description: "Could not find client_id in code chain." + }} + + _code -> + :ok + end + end + + def verify_token_against_chain(code_chain, token, alg) do + case Enum.any?(code_chain, fn + %Token{sub: sub, revoked_at: nil} -> + case VerifiablePresentations.verify_jwt({:did, sub}, alg, token) do + {:ok, _jwk, _claims} -> true + _ -> false + end + + _ -> + false + end) do + true -> + :ok + + false -> + {:error, + %Error{ + status: :bad_request, + error: :invalid_client, + error_description: "Could not verify given token in code chain." + }} + end + end + defp maybe_check_presentation( %{vp_token: vp_token, presentation_submission: presentation_submission}, presentation_definition @@ -347,6 +535,16 @@ defmodule Boruta.Openid do defp maybe_check_presentation(_, _), do: :ok + defp maybe_revoke_code_chain(%{credential: _credential}, code_chain) do + CodesAdapter.revoke(code_chain) + end + + defp maybe_revoke_code_chain(%{vp_token: _vp_token}, code_chain) do + CodesAdapter.revoke(code_chain) + end + + defp maybe_revoke_code_chain(%{id_token: _id_token}, code_chain), do: {:ok, code_chain} + alias Boruta.Openid.Json.Schema alias ExJsonSchema.Validator.Error.BorutaFormatter diff --git a/lib/boruta/openid/responses/credential_offer.ex b/lib/boruta/openid/responses/credential_offer.ex index f93d8769..79c53aff 100644 --- a/lib/boruta/openid/responses/credential_offer.ex +++ b/lib/boruta/openid/responses/credential_offer.ex @@ -5,6 +5,7 @@ defmodule Boruta.Openid.CredentialOfferResponse do @enforce_keys [:credential_issuer] defstruct credential_issuer: nil, + client_id: nil, # draft 13 credential_configuration_ids: [], # draft 11 @@ -12,7 +13,8 @@ defmodule Boruta.Openid.CredentialOfferResponse do grants: %{}, tx_code: nil, tx_code_required: nil, - redirect_uri: nil + redirect_uri: nil, + code: nil alias Boruta.Config alias Boruta.Oauth.Client @@ -20,6 +22,7 @@ defmodule Boruta.Openid.CredentialOfferResponse do @type t :: %__MODULE__{ credential_issuer: String.t(), + client_id: String.t(), credential_configuration_ids: list(String.t()), credentials: list(String.t()), grants: %{ @@ -27,7 +30,8 @@ defmodule Boruta.Openid.CredentialOfferResponse do }, tx_code: String.t(), tx_code_required: boolean(), - redirect_uri: String.t() + redirect_uri: String.t(), + code: Boruta.Oauth.Token.t() } def from_tokens( @@ -95,6 +99,7 @@ defmodule Boruta.Openid.CredentialOfferResponse do %__MODULE__{ credential_issuer: Config.issuer(), + client_id: preauthorized_code.public_client_id || Config.issuer(), credential_configuration_ids: credential_configuration_ids, credentials: credentials, tx_code: preauthorized_code.tx_code, @@ -103,7 +108,19 @@ defmodule Boruta.Openid.CredentialOfferResponse do "authorization_code" => %{} }, tx_code_required: preauthorized_code.client.enforce_tx_code, - redirect_uri: preauthorized_code.redirect_uri + redirect_uri: preauthorized_code.redirect_uri, + code: preauthorized_code } end + + @spec redirect_to_deeplink( + response :: t() + ) :: deeplink :: String.t() | {:error, reason :: String.t()} + def redirect_to_deeplink(%__MODULE__{} = response) do + "#{response.redirect_uri}?credential_offer=#{response + |> Map.from_struct() + |> Map.take([:credential_configuration_ids, :client_id, :credential_issuer, :grants]) + |> Jason.encode!() + |> URI.encode_www_form()}" + end end diff --git a/lib/boruta/openid/responses/direct_post.ex b/lib/boruta/openid/responses/direct_post.ex index 9db111b4..4b5fc1c4 100644 --- a/lib/boruta/openid/responses/direct_post.ex +++ b/lib/boruta/openid/responses/direct_post.ex @@ -7,15 +7,21 @@ defmodule Boruta.Openid.DirectPostResponse do :id_token, :vp_token, :code, + :code_chain, :redirect_uri, - :state + :response_types, + :state, + :error ] @type t :: %__MODULE__{ id_token: String.t() | nil, vp_token: String.t() | nil, code: Boruta.Oauth.Token.t(), + code_chain: list(Boruta.Oauth.Token.t()), redirect_uri: String.t(), - state: String.t() | nil + response_types: String.t(), + state: String.t() | nil, + error: Boruta.Oauth.Error.t() | nil } end diff --git a/lib/boruta/openid/verifiable_presentations.ex b/lib/boruta/openid/verifiable_presentations.ex index ff3081ff..467f9727 100644 --- a/lib/boruta/openid/verifiable_presentations.ex +++ b/lib/boruta/openid/verifiable_presentations.ex @@ -32,6 +32,17 @@ defmodule Boruta.Openid.VerifiablePresentations do end end + def response_types("id_token vp_token", scope, presentation_configuration) do + case Enum.any?(Map.keys(presentation_configuration), fn presentation_identifier -> + Enum.member?(Scope.split(scope), presentation_identifier) + end) do + true -> ["id_token", "vp_token"] + false -> ["id_token"] + end + end + + def response_types("id_token urn:ietf:params:oauth:response-type:pre-authorized_code", _scope, _presentation_configuration), do: ["id_token", "urn:ietf:params:oauth:response-type:pre-authorized_code"] + def presentation_definition(presentation_configuration, scope) do case Enum.find(presentation_configuration, fn {identifier, _configuration} -> Enum.member?(Scope.split(scope), identifier) diff --git a/priv/boruta/migrations/20250611214012_codes_response_type.ex b/priv/boruta/migrations/20250611214012_codes_response_type.ex new file mode 100644 index 00000000..fd1bd106 --- /dev/null +++ b/priv/boruta/migrations/20250611214012_codes_response_type.ex @@ -0,0 +1,15 @@ +defmodule Boruta.Migrations.CodesResponseType do + @moduledoc false + + defmacro __using__(_args) do + quote do + def change do + # 20250611193221_add_response_type_to_oauth_tokens.exs + alter table(:oauth_tokens) do + add :response_type, :string + end + end + end + end +end + diff --git a/priv/boruta/migrations/20250622165348_code_metadata_policy.ex b/priv/boruta/migrations/20250622165348_code_metadata_policy.ex new file mode 100644 index 00000000..90153501 --- /dev/null +++ b/priv/boruta/migrations/20250622165348_code_metadata_policy.ex @@ -0,0 +1,14 @@ +defmodule Boruta.Migrations.CodeMetadataPolicy do + @moduledoc false + + defmacro __using__(_args) do + quote do + def change do + # 20250622144833_add_metadata_policy_to_oauth_tokens.exs + alter table(:oauth_tokens) do + add :metadata_policy, :jsonb, default: "{}" + end + end + end + end +end diff --git a/priv/repo/migrations/20250611193221_add_response_type_to_oauth_tokens.exs b/priv/repo/migrations/20250611193221_add_response_type_to_oauth_tokens.exs new file mode 100644 index 00000000..1aa52c6e --- /dev/null +++ b/priv/repo/migrations/20250611193221_add_response_type_to_oauth_tokens.exs @@ -0,0 +1,9 @@ +defmodule Boruta.Repo.Migrations.AddResponseTypeToOauthTokens do + use Ecto.Migration + + def change do + alter table(:oauth_tokens) do + add :response_type, :string + end + end +end diff --git a/priv/repo/migrations/20250622144833_add_metadata_policy_to_oauth_tokens.exs b/priv/repo/migrations/20250622144833_add_metadata_policy_to_oauth_tokens.exs new file mode 100644 index 00000000..0cb77510 --- /dev/null +++ b/priv/repo/migrations/20250622144833_add_metadata_policy_to_oauth_tokens.exs @@ -0,0 +1,9 @@ +defmodule Boruta.Repo.Migrations.AddMetadataPolicyToOauthTokens do + use Ecto.Migration + + def change do + alter table(:oauth_tokens) do + add :metadata_policy, :jsonb, default: "{}" + end + end +end diff --git a/test/boruta/oauth/integration/authorization_code_grant_test.exs b/test/boruta/oauth/integration/authorization_code_grant_test.exs index ab94d820..440a34f8 100644 --- a/test/boruta/oauth/integration/authorization_code_grant_test.exs +++ b/test/boruta/oauth/integration/authorization_code_grant_test.exs @@ -954,6 +954,46 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ~r"#{redirect_uri}" end + test "returns a code with siopv2 - previous_code (direct_post)" do + redirect_uri = "openid:" + code = insert(:token, type: "code").value + + assert {:authorize_success, + %SiopV2Response{ + code: response_code, + client: client, + client_id: "did:key:test", + response_type: "id_token", + redirect_uri: ^redirect_uri, + scope: "openid", + issuer: issuer, + response_mode: "direct_post", + nonce: "nonce" + } = response} = + Oauth.authorize( + %Plug.Conn{ + query_params: %{ + "response_type" => "code", + "client_id" => "did:key:test", + "redirect_uri" => redirect_uri, + "client_metadata" => "{}", + "nonce" => "nonce", + "scope" => "openid", + "code" => code + } + }, + %ResourceOwner{sub: "did:key:test"}, + ApplicationMock + ) + + assert issuer == Boruta.Config.issuer() + assert client.public_client_id == Boruta.Config.issuer() + assert response_code.previous_code == code + + assert SiopV2Response.redirect_to_deeplink(response, fn code -> code end) =~ + ~r"#{redirect_uri}" + end + test "returns a code with siopv2 (post)" do redirect_uri = "openid://" client = insert(:client, response_mode: "post", redirect_uris: [redirect_uri]) diff --git a/test/boruta/oauth/integration/common_grant_test.exs b/test/boruta/oauth/integration/common_grant_test.exs index 51408218..8388a0f9 100644 --- a/test/boruta/oauth/integration/common_grant_test.exs +++ b/test/boruta/oauth/integration/common_grant_test.exs @@ -112,7 +112,7 @@ defmodule Boruta.OauthTest.CommonGrantTest do %Error{ error: :invalid_request, error_description: - "Invalid response_type param, may be one of `code` for Authorization Code request, `code id_token`, `code token`, `code id_token token` for Hybrid requests, or `token`, `id_token token` for Implicit requests.", + "Invalid response_type param.", status: :bad_request }} end diff --git a/test/boruta/openid/integration/credential_test.exs b/test/boruta/openid/integration/credential_test.exs index a0cf8db8..4bd54888 100644 --- a/test/boruta/openid/integration/credential_test.exs +++ b/test/boruta/openid/integration/credential_test.exs @@ -1,23 +1,36 @@ defmodule Boruta.OpenidTest.CredentialTest do - alias Boruta.Openid.DeferedCredentialResponse - use Boruta.DataCase + use Boruta.DataCase, async: false import Boruta.Factory import Plug.Conn import Mox alias Boruta.Config + alias Boruta.Ecto.Client + alias Boruta.Ecto.ClientStore alias Boruta.Ecto.Token alias Boruta.Oauth.Error alias Boruta.Oauth.ResourceOwner alias Boruta.Openid alias Boruta.Openid.ApplicationMock alias Boruta.Openid.CredentialResponse + alias Boruta.Openid.DeferedCredentialResponse alias Boruta.Openid.VerifiableCredentials setup :verify_on_exit! describe "deliver verifiable credentials" do + setup do + :ok = ClientStore.invalidate_public() + + {:ok, client} = + Repo.get_by(Client, public_client_id: Boruta.Config.issuer()) + |> Ecto.Changeset.change(%{check_public_client_id: false}) + |> Repo.update() + + {:ok, public_client: client} + end + test "returns an error with no access token" do conn = %Plug.Conn{} @@ -69,7 +82,35 @@ defmodule Boruta.OpenidTest.CredentialTest do status: :bad_request, error: :invalid_request, error_description: - "Request body validation failed. Required properties format, proof are missing at #." + "Request body validation failed. Required properties format, proof are missing at #." + }} + end + + test "returns an error with an access token without a previous code" do + credential_params = %{ + "credential_identifier" => "identifier", + "format" => "jwt_vc", + "proof" => %{"proof_type" => "jwt", "jwt" => ""} + } + + sub = SecureRandom.uuid() + + expect(Boruta.Support.ResourceOwners, :get_by, fn sub: ^sub, scope: _scope -> + {:ok, %ResourceOwner{sub: sub}} + end) + + %Token{value: access_token} = insert(:token, sub: sub) + + conn = + %Plug.Conn{} + |> put_req_header("authorization", "Bearer #{access_token}") + + assert Openid.credential(conn, credential_params, %{}, ApplicationMock) == + {:credential_failure, + %Error{ + status: :bad_request, + error: :invalid_request, + error_description: "Code not found." }} end @@ -86,7 +127,8 @@ defmodule Boruta.OpenidTest.CredentialTest do {:ok, %ResourceOwner{sub: sub}} end) - %Token{value: access_token} = insert(:token, sub: sub) + %Token{value: access_token} = + insert(:token, sub: sub, previous_code: insert(:token, type: "preauthorized_code").value) conn = %Plug.Conn{} @@ -124,7 +166,12 @@ defmodule Boruta.OpenidTest.CredentialTest do "jwt" => token } - credential_params = %{"format" => "jwt_vc", "proof" => proof, "credential_identifier" => "VerifiableCredential"} + credential_params = %{ + "format" => "jwt_vc", + "proof" => proof, + "credential_identifier" => "VerifiableCredential" + } + sub = SecureRandom.uuid() expect(Boruta.Support.ResourceOwners, :get_by, fn sub: ^sub, scope: _scope -> @@ -148,7 +195,8 @@ defmodule Boruta.OpenidTest.CredentialTest do %Token{value: access_token} = insert(:token, sub: sub, - authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}] + authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}], + previous_code: insert(:token, type: "preauthorized_code").value ) conn = @@ -156,10 +204,266 @@ defmodule Boruta.OpenidTest.CredentialTest do |> put_req_header("authorization", "Bearer #{access_token}") assert {:credential_created, - %CredentialResponse{ - format: "jwt_vc", - credential: credential - }} = Openid.credential(conn, credential_params, %{}, ApplicationMock) + %CredentialResponse{ + format: "jwt_vc", + credential: credential + }} = Openid.credential(conn, credential_params, %{}, ApplicationMock) + + # TODO validate credential body + assert credential + end + + @tag :skip + test "returns an error with invalid code chain", %{public_client: client} do + {_, public_jwk} = public_key_fixture() |> JOSE.JWK.from_pem() |> JOSE.JWK.to_map() + + signer = + Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ + "jwk" => public_jwk, + "typ" => "openid4vci-proof+jwt" + }) + + {:ok, token, _claims} = + VerifiableCredentials.Token.generate_and_sign( + %{ + "aud" => Config.issuer(), + "iat" => :os.system_time(:seconds) + }, + signer + ) + + proof = %{ + "proof_type" => "jwt", + "jwt" => token + } + + credential_params = %{ + "format" => "jwt_vc", + "proof" => proof, + "credential_identifier" => "VerifiableCredential" + } + + sub = SecureRandom.uuid() + + expect(Boruta.Support.ResourceOwners, :get_by, fn sub: ^sub, scope: _scope -> + {:ok, + %ResourceOwner{ + sub: sub, + credential_configuration: %{ + "VerifiableCredential" => %{ + version: "13", + format: "jwt_vc", + time_to_live: 3600, + claims: ["family_name"] + } + }, + extra_claims: %{ + "family_name" => "family_name" + } + }} + end) + + invalid_code_chain = [ + insert( + :token, + [{:type, "code"}, {:previous_code, "invalid_code_2"}, {:value, "invalid_code_1"}] + ), + insert( + :token, + [{:type, "code"}, {:sub, "did:key:invalid"}, {:value, "invalid_code_2"}] + ) + ] + + %Token{value: access_token} = + insert(:token, + client: client, + sub: sub, + authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}], + previous_code: List.first(invalid_code_chain).value + ) + + conn = + %Plug.Conn{} + |> put_req_header("authorization", "Bearer #{access_token}") + + assert { + :credential_failure, + %Boruta.Oauth.Error{ + error: :invalid_client, + error_description: "Could not verify given token in code chain.", + status: :bad_request + } + } = Openid.credential(conn, credential_params, %{}, ApplicationMock) + end + + test "returns an error with invalid code chain (policy)", %{public_client: client} do + wallet_did = + "did:key:z4MXj1wBzi9jUstyQAVUF6ibbHUd3jozWgVWFNHUEd8WFtuQAcRojJDf97jQeR6nA5PXoYC3nb1BrjbYQrxRWinvz5tjtMxT4fFTtHkxjojdoSyEdRBgEupBfhz5axKi9WE5hLS4eiwGLuaQWUq48manvZjSHUi3azj8exMDx2XKjHSeB2BuNr9Bwse3ts9MctQrNtDg2LP1R7ZRdUWQuqLzZ87bQJgJZ7BWqA92dfMcgZ17ZysNZmSfUgXxFXhyb42N8wnG8wxdWprmJv9wBsEXjcCUiJhdTu8NGABQQ2QNhNYVuwfHgCCsZqxkmVXMN9kynQV2NCNkPkLxNP3VzSMw7FLjLFMsnyPXd4ph9yyYF3iDmVKtC" + + signer = + Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ + "kid" => wallet_did, + "typ" => "openid4vci-proof+jwt" + }) + + {:ok, token, _claims} = + VerifiableCredentials.Token.generate_and_sign( + %{ + "aud" => Config.issuer(), + "iat" => :os.system_time(:seconds) + }, + signer + ) + + proof = %{ + "proof_type" => "jwt", + "jwt" => token + } + + credential_params = %{ + "format" => "jwt_vc", + "proof" => proof, + "credential_identifier" => "VerifiableCredential" + } + + sub = SecureRandom.uuid() + + expect(Boruta.Support.ResourceOwners, :get_by, fn sub: ^sub, scope: _scope -> + {:ok, + %ResourceOwner{ + sub: sub, + credential_configuration: %{ + "VerifiableCredential" => %{ + version: "13", + format: "jwt_vc", + time_to_live: 3600, + claims: ["family_name"] + } + }, + extra_claims: %{ + "family_name" => "family_name" + } + }} + end) + + invalid_code_chain = [ + insert( + :token, + [{:type, "code"}, {:previous_code, "invalid_code_2"}, {:value, "invalid_code_1"}] + ), + insert( + :token, + [ + {:type, "code"}, + {:sub, "did:key:invalid"}, + {:value, "invalid_code_2"}, + {:metadata_policy, %{"client_id" => %{"one_of" => ["did:key:test"]}}} + ] + ) + ] + + %Token{value: access_token} = + insert(:token, + client: client, + sub: sub, + authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}], + previous_code: List.first(invalid_code_chain).value + ) + + conn = + %Plug.Conn{} + |> put_req_header("authorization", "Bearer #{access_token}") + + assert { + :credential_failure, + %Boruta.Oauth.Error{ + error: :unauthorized, + error_description: "Metadata policies check failed.", + status: :unauthorized + } + } = Openid.credential(conn, credential_params, %{}, ApplicationMock) + end + + test "returns a credential with a public client", %{public_client: client} do + wallet_did = + "did:key:z4MXj1wBzi9jUstyQAVUF6ibbHUd3jozWgVWFNHUEd8WFtuQAcRojJDf97jQeR6nA5PXoYC3nb1BrjbYQrxRWinvz5tjtMxT4fFTtHkxjojdoSyEdRBgEupBfhz5axKi9WE5hLS4eiwGLuaQWUq48manvZjSHUi3azj8exMDx2XKjHSeB2BuNr9Bwse3ts9MctQrNtDg2LP1R7ZRdUWQuqLzZ87bQJgJZ7BWqA92dfMcgZ17ZysNZmSfUgXxFXhyb42N8wnG8wxdWprmJv9wBsEXjcCUiJhdTu8NGABQQ2QNhNYVuwfHgCCsZqxkmVXMN9kynQV2NCNkPkLxNP3VzSMw7FLjLFMsnyPXd4ph9yyYF3iDmVKtC" + + {_, public_jwk} = public_key_fixture() |> JOSE.JWK.from_pem() |> JOSE.JWK.to_map() + + signer = + Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ + "jwk" => public_jwk, + "typ" => "openid4vci-proof+jwt" + }) + + {:ok, token, _claims} = + VerifiableCredentials.Token.generate_and_sign( + %{ + "aud" => Config.issuer(), + "iat" => :os.system_time(:seconds) + }, + signer + ) + + proof = %{ + "proof_type" => "jwt", + "jwt" => token + } + + credential_params = %{ + "format" => "jwt_vc", + "proof" => proof, + "credential_identifier" => "VerifiableCredential" + } + + sub = SecureRandom.uuid() + + expect(Boruta.Support.ResourceOwners, :get_by, fn sub: ^sub, scope: _scope -> + {:ok, + %ResourceOwner{ + sub: sub, + credential_configuration: %{ + "VerifiableCredential" => %{ + version: "13", + format: "jwt_vc", + time_to_live: 3600, + claims: ["family_name"] + } + }, + extra_claims: %{ + "family_name" => "family_name" + } + }} + end) + + valid_code_chain = [ + insert( + :token, + [{:type, "code"}, {:previous_code, "middle_code_2"}, {:value, "middle_code_1"}] + ), + insert( + :token, + [{:type, "code"}, {:sub, wallet_did}, {:value, "middle_code_2"}] + ) + ] + + %Token{value: access_token} = + insert(:token, + client: client, + sub: sub, + authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}], + previous_code: List.first(valid_code_chain).value + ) + + conn = + %Plug.Conn{} + |> put_req_header("authorization", "Bearer #{access_token}") + + assert {:credential_created, + %CredentialResponse{ + format: "jwt_vc", + credential: credential + }} = Openid.credential(conn, credential_params, %{}, ApplicationMock) # TODO validate credential body assert credential @@ -218,7 +522,7 @@ defmodule Boruta.OpenidTest.CredentialTest do status: :bad_request, error: :invalid_request, error_description: - "Request body validation failed. Required properties format, proof are missing at #." + "Request body validation failed. Required properties format, proof are missing at #." }} end @@ -235,7 +539,8 @@ defmodule Boruta.OpenidTest.CredentialTest do {:ok, %ResourceOwner{sub: sub}} end) - %Token{value: access_token} = insert(:token, sub: sub) + %Token{value: access_token} = + insert(:token, sub: sub, previous_code: insert(:token, type: "preauthorized_code").value) conn = %Plug.Conn{} @@ -273,7 +578,12 @@ defmodule Boruta.OpenidTest.CredentialTest do "jwt" => token } - credential_params = %{"format" => "jwt_vc", "proof" => proof, "credential_identifier" => "VerifiableCredential"} + credential_params = %{ + "format" => "jwt_vc", + "proof" => proof, + "credential_identifier" => "VerifiableCredential" + } + sub = SecureRandom.uuid() expect(Boruta.Support.ResourceOwners, :get_by, fn sub: ^sub, scope: _scope -> @@ -298,7 +608,8 @@ defmodule Boruta.OpenidTest.CredentialTest do %Token{value: access_token} = insert(:token, sub: sub, - authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}] + authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}], + previous_code: insert(:token, type: "preauthorized_code").value ) conn = @@ -306,9 +617,9 @@ defmodule Boruta.OpenidTest.CredentialTest do |> put_req_header("authorization", "Bearer #{access_token}") assert {:credential_created, - %DeferedCredentialResponse{ - acceptance_token: acceptance_token, - }} = Openid.credential(conn, credential_params, %{}, ApplicationMock) + %DeferedCredentialResponse{ + acceptance_token: acceptance_token + }} = Openid.credential(conn, credential_params, %{}, ApplicationMock) assert acceptance_token end @@ -336,7 +647,12 @@ defmodule Boruta.OpenidTest.CredentialTest do "jwt" => token } - credential_params = %{"format" => "jwt_vc", "proof" => proof, "credential_identifier" => "VerifiableCredential"} + credential_params = %{ + "format" => "jwt_vc", + "proof" => proof, + "credential_identifier" => "VerifiableCredential" + } + sub = SecureRandom.uuid() expect(Boruta.Support.ResourceOwners, :get_by, fn sub: ^sub, scope: _scope -> @@ -361,7 +677,8 @@ defmodule Boruta.OpenidTest.CredentialTest do %Token{value: access_token} = insert(:token, sub: sub, - authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}] + authorization_details: [%{"credential_identifiers" => ["VerifiableCredential"]}], + previous_code: insert(:token, type: "preauthorized_code").value ) conn = @@ -369,18 +686,18 @@ defmodule Boruta.OpenidTest.CredentialTest do |> put_req_header("authorization", "Bearer #{access_token}") assert {:credential_created, - %DeferedCredentialResponse{ - acceptance_token: acceptance_token, - }} = Openid.credential(conn, credential_params, %{}, ApplicationMock) + %DeferedCredentialResponse{ + acceptance_token: acceptance_token + }} = Openid.credential(conn, credential_params, %{}, ApplicationMock) conn = %Plug.Conn{} |> put_req_header("authorization", "Bearer #{acceptance_token}") assert {:credential_created, - %CredentialResponse{ - credential: credential - }} = Openid.defered_credential(conn, ApplicationMock) + %CredentialResponse{ + credential: credential + }} = Openid.defered_credential(conn, ApplicationMock) # TODO validate credential body assert credential diff --git a/test/boruta/openid/integration/direct_post_test.exs b/test/boruta/openid/integration/direct_post_test.exs index f6c52f26..3f0533d8 100644 --- a/test/boruta/openid/integration/direct_post_test.exs +++ b/test/boruta/openid/integration/direct_post_test.exs @@ -1,9 +1,10 @@ defmodule Boruta.OpenidTest.DirectPostTest do - use Boruta.DataCase + use Boruta.DataCase, async: false import Boruta.Factory alias Boruta.Ecto.Client + alias Boruta.Ecto.ClientStore alias Boruta.Oauth alias Boruta.Openid alias Boruta.Openid.ApplicationMock @@ -12,9 +13,12 @@ defmodule Boruta.OpenidTest.DirectPostTest do describe "authenticates with direct post response" do setup do - {:ok, client} = Repo.get_by(Client, public_client_id: Boruta.Config.issuer()) - |> Ecto.Changeset.change(%{check_public_client_id: true}) - |> Repo.update() + :ok = ClientStore.invalidate_public() + + {:ok, client} = + Repo.get_by(Client, public_client_id: Boruta.Config.issuer()) + |> Ecto.Changeset.change(%{check_public_client_id: false}) + |> Repo.update() wallet_did = "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ" @@ -56,6 +60,75 @@ defmodule Boruta.OpenidTest.DirectPostTest do public_client_code = insert(:token, [{:public_client_id, wallet_did} | code_params]) + last_valid_code_chain = [ + insert( + :token, + [{:public_client_id, wallet_did}, {:previous_code, "last_code_1"}] ++ code_params + ), + insert( + :token, + [{:previous_code, "last_code_2"}, {:value, "last_code_1"}] ++ + code_params + ), + insert(:token, [{:value, "last_code_2"}] ++ code_params) + ] + + middle_valid_code_chain = [ + insert( + :token, + [{:public_client_id, "did:key:other"}, {:previous_code, "middle_code_1"}] ++ code_params + ), + insert( + :token, + [{:previous_code, "middle_code_2"}, {:value, "middle_code_1"}] ++ + code_params + ), + insert(:token, [{:sub, wallet_did}, {:value, "middle_code_2"}] ++ code_params) + ] + + replay_code_chain = [ + insert( + :token, + [{:public_client_id, "did:key:other"}, {:previous_code, "middle_code_1"}] ++ code_params + ), + Enum.at(middle_valid_code_chain, 1), + Enum.at(middle_valid_code_chain, 2) + ] + + invalid_policy_code_chain = [ + insert( + :token, + [{:public_client_id, wallet_did}, {:previous_code, "invalid_policy_code_1"}] ++ code_params + ), + insert( + :token, + [ + {:previous_code, "invalid_policy_code_2"}, + {:value, "invalid_policy_code_1"}, + {:metadata_policy, %{"client_id" => %{"one_of" => ["did:key:test"]}}} + ] ++ + code_params + ), + insert(:token, [{:value, "invalid_policy_code_2"}] ++ code_params) + ] + + policy_code_chain = [ + insert( + :token, + [{:public_client_id, wallet_did}, {:previous_code, "policy_code_1"}] ++ code_params + ), + insert( + :token, + [ + {:previous_code, "policy_code_2"}, + {:value, "policy_code_1"}, + {:metadata_policy, %{"client_id" => %{"one_of" => [wallet_did]}}} + ] ++ + code_params + ), + insert(:token, [{:value, "policy_code_2"}] ++ code_params) + ] + pkce_code = insert(:token, type: "code", @@ -134,6 +207,11 @@ defmodule Boruta.OpenidTest.DirectPostTest do pkce_code: pkce_code, public_client_code: public_client_code, bad_public_client_code: bad_public_client_code, + last_valid_code_chain: last_valid_code_chain, + middle_valid_code_chain: middle_valid_code_chain, + replay_code_chain: replay_code_chain, + invalid_policy_code_chain: invalid_policy_code_chain, + policy_code_chain: policy_code_chain, id_token: id_token, vp_token: vp_token} end @@ -245,38 +323,6 @@ defmodule Boruta.OpenidTest.DirectPostTest do ) end - test "siopv2 - returns an error on replay", %{id_token: id_token, code: code} do - conn = %Plug.Conn{} - - assert {:direct_post_success, _response} = - Openid.direct_post( - conn, - %{ - code_id: code.id, - id_token: id_token - }, - ApplicationMock - ) - - assert { - :authentication_failure, - %Boruta.Oauth.Error{ - status: :bad_request, - format: :query, - error: :invalid_grant, - error_description: "Given authorization code is invalid, revoked, or expired." - } - } = - Openid.direct_post( - conn, - %{ - code_id: code.id, - id_token: id_token - }, - ApplicationMock - ) - end - test "siopv2 - returns an error with pkce client without code_verifier", %{ id_token: id_token, pkce_code: code @@ -332,22 +378,13 @@ defmodule Boruta.OpenidTest.DirectPostTest do ) end - test "siopv2 - returns an error with bad public client", %{ + test "siopv2 - authenticates with bad public client", %{ id_token: id_token, bad_public_client_code: code } do conn = %Plug.Conn{} - assert {:authentication_failure, - %Boruta.Oauth.Error{ - status: :bad_request, - error: :invalid_client, - error_description: - "Authorization client_id do not match vp_token signature.", - format: :query, - redirect_uri: "http://redirect.uri", - state: "state" - }} = + assert {:direct_post_success, _response} = Openid.direct_post( conn, %{ @@ -672,6 +709,7 @@ defmodule Boruta.OpenidTest.DirectPostTest do ) end + @tag :skip test "oid4vp - returns an error with bad public client", %{ vp_token: vp_token, bad_public_client_code: code @@ -700,8 +738,7 @@ defmodule Boruta.OpenidTest.DirectPostTest do %Boruta.Oauth.Error{ status: :bad_request, error: :invalid_client, - error_description: - "Authorization client_id do not match vp_token signature.", + error_description: "Could not verify given token in code chain.", format: :query, redirect_uri: "http://redirect.uri", state: "state" @@ -796,6 +833,196 @@ defmodule Boruta.OpenidTest.DirectPostTest do assert response.state == code.state end + @tag :skip + test "oid4vp - authenticates with a code chain (last valid)", %{ + vp_token: vp_token, + last_valid_code_chain: [code | _code_chain] + } do + conn = %Plug.Conn{} + + presentation_submission = + Jason.encode!(%{ + "id" => "test", + "definition_id" => "test", + "descriptor_map" => [ + %{ + "id" => "test", + "format" => "jwt_vp", + "path" => "$", + "path_nested" => %{ + "id" => "test", + "format" => "jwt_vc", + "path" => "$.vp.verifiableCredential[0]" + } + } + ] + }) + + assert {:direct_post_success, response} = + Openid.direct_post( + conn, + %{ + code_id: code.id, + vp_token: vp_token, + presentation_submission: presentation_submission + }, + ApplicationMock + ) + + assert response.vp_token + assert response.redirect_uri == code.redirect_uri + assert response.code.value == code.value + assert Enum.count(response.code_chain) == 3 + assert response.state == code.state + end + + test "oid4vp - returns an error with a code chain (policy invalid)", %{ + vp_token: vp_token, + invalid_policy_code_chain: [code | _code_chain] + } do + conn = %Plug.Conn{} + + presentation_submission = + Jason.encode!(%{ + "id" => "test", + "definition_id" => "test", + "descriptor_map" => [ + %{ + "id" => "test", + "format" => "jwt_vp", + "path" => "$", + "path_nested" => %{ + "id" => "test", + "format" => "jwt_vc", + "path" => "$.vp.verifiableCredential[0]" + } + } + ] + }) + + assert { + :authentication_failure, + %Boruta.Oauth.Error{ + status: :unauthorized, + error: :unauthorized, + error_description: "Metadata policies check failed.", + format: :query, + redirect_uri: "http://redirect.uri", + state: "state" + } + } = + Openid.direct_post( + conn, + %{ + code_id: code.id, + vp_token: vp_token, + presentation_submission: presentation_submission + }, + ApplicationMock + ) + end + + test "oid4vp - authenticates with a code chain (policy)", %{ + vp_token: vp_token, + policy_code_chain: [code | _code_chain] + } do + conn = %Plug.Conn{} + + presentation_submission = + Jason.encode!(%{ + "id" => "test", + "definition_id" => "test", + "descriptor_map" => [ + %{ + "id" => "test", + "format" => "jwt_vp", + "path" => "$", + "path_nested" => %{ + "id" => "test", + "format" => "jwt_vc", + "path" => "$.vp.verifiableCredential[0]" + } + } + ] + }) + + assert {:direct_post_success, _response} = + Openid.direct_post( + conn, + %{ + code_id: code.id, + vp_token: vp_token, + presentation_submission: presentation_submission + }, + ApplicationMock + ) + end + + @tag :skip + test "oid4vp - returns an error with a code chain (middle valid - replay)", %{ + vp_token: vp_token, + middle_valid_code_chain: [code | _code_chain], + replay_code_chain: [replay_code | _replay_code_chain] + } do + conn = %Plug.Conn{} + + presentation_submission = + Jason.encode!(%{ + "id" => "test", + "definition_id" => "test", + "descriptor_map" => [ + %{ + "id" => "test", + "format" => "jwt_vp", + "path" => "$", + "path_nested" => %{ + "id" => "test", + "format" => "jwt_vc", + "path" => "$.vp.verifiableCredential[0]" + } + } + ] + }) + + assert {:direct_post_success, response} = + Openid.direct_post( + conn, + %{ + code_id: code.id, + vp_token: vp_token, + presentation_submission: presentation_submission + }, + ApplicationMock + ) + + assert response.vp_token + assert response.redirect_uri == code.redirect_uri + assert response.code.value == code.value + assert Enum.count(response.code_chain) == 3 + assert response.state == code.state + + assert { + :authentication_failure, + %Boruta.Oauth.Error{ + status: :bad_request, + error: :invalid_client, + error_description: "Authorization client_id do not match vp_token signature.", + format: :query, + redirect_uri: "http://redirect.uri", + state: "state" + } + } = + Openid.direct_post( + conn, + %{ + code_id: replay_code.id, + vp_token: vp_token, + presentation_submission: presentation_submission + }, + ApplicationMock + ) + end + test "oid4vp - authenticates with code verifier (plain code challenge)", %{ vp_token: vp_token, pkce_code: code diff --git a/test/boruta/openid/integration/preauthorized_code_grant_test.exs b/test/boruta/openid/integration/preauthorized_code_grant_test.exs index 2047720d..d1d017e3 100644 --- a/test/boruta/openid/integration/preauthorized_code_grant_test.exs +++ b/test/boruta/openid/integration/preauthorized_code_grant_test.exs @@ -11,6 +11,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.TokenResponse alias Boruta.Openid.CredentialOfferResponse + alias Boruta.Repo alias Boruta.Support.ResourceOwners alias Boruta.Support.User @@ -107,7 +108,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do error: :invalid_resource_owner, error_description: "Resource owner is invalid.", status: :unauthorized, - format: :fragment, + format: :query, redirect_uri: redirect_uri }} end @@ -122,7 +123,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do # %Boruta.Oauth.Error{ # error: :unknown_error, # error_description: "\"Could not create code : sub is invalid\"", - # format: :fragment, + # format: :query, # redirect_uri: "https://redirect.uri", # state: nil, # status: :internal_server_error @@ -167,7 +168,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do %Error{ error: :invalid_scope, error_description: "Given scopes are unknown or unauthorized.", - format: :fragment, + format: :query, redirect_uri: "https://redirect.uri", status: :bad_request }} @@ -192,11 +193,10 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do %Boruta.Oauth.Error{ error: :invalid_agent_token, error_description: "Agent token is invalid", - format: :fragment, + format: :query, redirect_uri: "https://redirect.uri", status: :unauthorized - } - } = + }} = Oauth.authorize( %Plug.Conn{ query_params: %{ @@ -233,12 +233,91 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do %Error{ error: :unsupported_grant_type, error_description: "Client do not support given grant type.", - format: :fragment, + format: :query, redirect_uri: redirect_uri, status: :bad_request }} end + test "returns an error with a bad code", %{ + client: client, + resource_owner: resource_owner + } do + redirect_uri = List.first(client.redirect_uris) + + resource_owner = %{ + resource_owner + | authorization_details: [ + %{ + "credential_configuration_id" => "credential" + } + ] + } + + assert { + :authorize_error, + %Boruta.Oauth.Error{ + redirect_uri: "https://redirect.uri", + error: :invalid_grant, + error_description: "Given authorization code is invalid, revoked, or expired.", + format: :query, + status: :bad_request + } + } = + Oauth.authorize( + %Plug.Conn{ + query_params: %{ + "response_type" => "urn:ietf:params:oauth:response-type:pre-authorized_code", + "client_id" => client.id, + "redirect_uri" => redirect_uri, + "code" => "bad code" + } + }, + resource_owner, + ApplicationMock + ) + end + + test "returns an error with a revoked code", %{ + client: client, + resource_owner: resource_owner + } do + redirect_uri = List.first(client.redirect_uris) + code = insert(:token, type: "code", revoked_at: DateTime.utc_now()) + + resource_owner = %{ + resource_owner + | authorization_details: [ + %{ + "credential_configuration_id" => "credential" + } + ] + } + + assert { + :authorize_error, + %Boruta.Oauth.Error{ + redirect_uri: "https://redirect.uri", + error: :invalid_grant, + error_description: "Given authorization code is invalid, revoked, or expired.", + format: :query, + status: :bad_request + } + } = + Oauth.authorize( + %Plug.Conn{ + query_params: %{ + "response_type" => "urn:ietf:params:oauth:response-type:pre-authorized_code", + "client_id" => client.id, + "redirect_uri" => redirect_uri, + "code" => code.value + } + }, + resource_owner, + ApplicationMock + ) + end + test "returns a credential offer response (draft 13)", %{ client: client, resource_owner: resource_owner @@ -281,11 +360,60 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do assert preauthorized_code end + test "returns a credential offer response with a code (draft 13)", %{ + client: client, + resource_owner: resource_owner + } do + redirect_uri = List.first(client.redirect_uris) + code = insert(:token, type: "code") + + resource_owner = %{ + resource_owner + | authorization_details: [ + %{ + "credential_configuration_id" => "credential" + } + ] + } + + assert {:authorize_success, + %CredentialOfferResponse{ + credential_issuer: "boruta", + redirect_uri: ^redirect_uri, + tx_code_required: false, + credential_configuration_ids: ["credential"], + grants: %{ + "urn:ietf:params:oauth:grant-type:pre-authorized_code" => %{ + "pre-authorized_code" => preauthorized_code + } + } + }} = + Oauth.authorize( + %Plug.Conn{ + query_params: %{ + "response_type" => "urn:ietf:params:oauth:response-type:pre-authorized_code", + "client_id" => client.id, + "redirect_uri" => redirect_uri, + "code" => code.value + } + }, + resource_owner, + ApplicationMock + ) + + assert preauthorized_code + + assert Repo.get_by(Ecto.Token, type: "preauthorized_code", value: preauthorized_code).previous_code == + code.value + end + test "returns a credential offer response (agent_token)", %{ client: client, resource_owner: resource_owner } do - agent_token = insert(:token, type: "agent_token", bind_data: %{test: true}, bind_configuration: %{}) + agent_token = + insert(:token, type: "agent_token", bind_data: %{test: true}, bind_configuration: %{}) + redirect_uri = List.first(client.redirect_uris) resource_owner = %{ @@ -322,7 +450,10 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do ) assert preauthorized_code - assert %Ecto.Token{agent_token: agent_token} = Repo.get_by(Boruta.Ecto.Token, value: preauthorized_code) + + assert %Ecto.Token{agent_token: agent_token} = + Repo.get_by(Boruta.Ecto.Token, value: preauthorized_code) + assert agent_token end @@ -744,7 +875,9 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do ] ) - agent_token = insert(:token, type: "agent_token", bind_data: %{test: true}, bind_configuration: %{}) + agent_token = + insert(:token, type: "agent_token", bind_data: %{test: true}, bind_configuration: %{}) + agent_code = insert( :token,