diff --git a/.credo.exs b/.credo.exs index 6dc7b790..5e66ab55 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 @@ -129,6 +130,7 @@ {Credo.Check.Warning.RaiseInsideRescue, []}, # TODO enable spec with struct check {Credo.Check.Warning.SpecWithStruct, false}, + {Credo.Check.Warning.StructFieldAmount, [max_fields: 40]}, {Credo.Check.Warning.UnusedEnumOperation, []}, {Credo.Check.Warning.UnusedFileOperation, []}, {Credo.Check.Warning.UnusedKeywordOperation, []}, diff --git a/CHANGELOG.md b/CHANGELOG.md index 00dd7ca8..14b7bbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `AuthorizeResponse` result expose an `Oauth.Token` struct instead of value + ### Added - OAuth Resource Indicators support for authorization, token, refresh, and introspection flows, with RFC 8707 validation and optional client-authorized resources diff --git a/lib/boruta/adapters/codes.ex b/lib/boruta/adapters/codes.ex index d216ed60..b945c179 100644 --- a/lib/boruta/adapters/codes.ex +++ b/lib/boruta/adapters/codes.ex @@ -9,6 +9,9 @@ defmodule Boruta.CodesAdapter do def get_by(params), do: codes().get_by(params) def create(params), do: codes().create(params) + def update_client_encryption(code, params), do: codes().update_client_encryption(code, 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/access_tokens.ex b/lib/boruta/adapters/ecto/access_tokens.ex index 7044d312..ee651f46 100644 --- a/lib/boruta/adapters/ecto/access_tokens.ex +++ b/lib/boruta/adapters/ecto/access_tokens.ex @@ -58,6 +58,7 @@ defmodule Boruta.Ecto.AccessTokens do redirect_uri = params[:redirect_uri] previous_token = params[:previous_token] previous_code = params[:previous_code] + requested_scope = params[:requested_scope] resource_owner = params[:resource_owner] agent_token = params[:agent_token] @@ -70,6 +71,7 @@ defmodule Boruta.Ecto.AccessTokens do redirect_uri: redirect_uri, state: state, scope: scope, + requested_scope: requested_scope || "", resource: resource, access_token_ttl: access_token_ttl, previous_token: previous_token, diff --git a/lib/boruta/adapters/ecto/agent_tokens.ex b/lib/boruta/adapters/ecto/agent_tokens.ex index d27f4d01..5f22022d 100644 --- a/lib/boruta/adapters/ecto/agent_tokens.ex +++ b/lib/boruta/adapters/ecto/agent_tokens.ex @@ -58,6 +58,7 @@ defmodule Boruta.Ecto.AgentTokens do redirect_uri = params[:redirect_uri] previous_token = params[:previous_token] previous_code = params[:previous_code] + requested_scope = params[:requested_scope] resource_owner = params[:resource_owner] bind_data = params[:bind_data] bind_configuration = params[:bind_configuration] @@ -71,6 +72,7 @@ defmodule Boruta.Ecto.AgentTokens do redirect_uri: redirect_uri, state: state, scope: scope, + requested_scope: requested_scope || "", resource: resource, access_token_ttl: agent_token_ttl, previous_token: previous_token, diff --git a/lib/boruta/adapters/ecto/clients.ex b/lib/boruta/adapters/ecto/clients.ex index 97e9bfd1..ddc05742 100644 --- a/lib/boruta/adapters/ecto/clients.ex +++ b/lib/boruta/adapters/ecto/clients.ex @@ -123,9 +123,11 @@ defmodule Boruta.Ecto.Clients do end end - defp rsa_key(%Client{public_key: public_key, private_key: private_key}) do + defp rsa_key(%Client{public_key: public_key, private_key: private_key, id_token_signature_alg: id_token_signature_alg}) do {_type, jwk} = public_key |> :jose_jwk.from_pem() |> :jose_jwk.to_map() - Map.put(jwk, "kid", Oauth.Client.Crypto.kid_from_private_key(private_key)) + jwk + |> Map.put("kid", Oauth.Client.Crypto.kid_from_private_key(private_key)) + |> Map.put("alg", id_token_signature_alg) end end diff --git a/lib/boruta/adapters/ecto/codes.ex b/lib/boruta/adapters/ecto/codes.ex index ff29ff3f..d990fc25 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,19 +106,24 @@ 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, state: state, nonce: params[:nonce], scope: scope, + requested_scope: params[:requested_scope] || "", resource: params[:resource], authorization_code_ttl: authorization_code_ttl, code_challenge: code_challenge, 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], + client_encryption_key: params[:client_encryption_key], + client_encryption_alg: params[:client_encryption_alg], + previous_code: params[:previous_code] } ]) @@ -131,13 +142,62 @@ defmodule Boruta.Ecto.Codes do defp changeset_method(%Oauth.Client{pkce: true}), do: :pkce_code_changeset @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, token} <- TokenStore.put(to_oauth_schema(code)) do + {:ok, token} + else + _ -> + {:error, "Preauthorized code not found."} + end + end + + @impl Boruta.Oauth.Codes + def update_client_encryption(%Oauth.Token{value: value}, params) do + with %Token{} = token <- repo().get_by(Token, value: value), + {:ok, token} <- Token.client_encryption_changeset(token, params) |> repo().update(), + token <- to_oauth_schema(token) do + TokenStore.put(token) + end + end + + @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} <- Token.revoke_changeset(token) |> repo().update(), {:ok, _token} <- TokenStore.invalidate(code) do - {:ok, token} + {:ok, %{code | revoked_at: token.revoked_at}} else nil -> {:error, "Code not found."} @@ -156,4 +216,33 @@ defmodule Boruta.Ecto.Codes do {:ok, code} 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/oauth_mapper.ex b/lib/boruta/adapters/ecto/oauth_mapper.ex index bfa9477c..41beb763 100644 --- a/lib/boruta/adapters/ecto/oauth_mapper.ex +++ b/lib/boruta/adapters/ecto/oauth_mapper.ex @@ -8,11 +8,11 @@ end defimpl Boruta.Ecto.OauthMapper, for: Boruta.Ecto.Token do import Boruta.Config, only: [repo: 0, resource_owners: 0, clients: 0] - alias Boruta.Oauth - alias Boruta.Oauth.ResourceOwner alias Boruta.Ecto alias Boruta.Ecto.AgentTokens alias Boruta.Ecto.OauthMapper + alias Boruta.Oauth + alias Boruta.Oauth.ResourceOwner def to_oauth_schema(%Ecto.Token{} = token) do client = @@ -33,15 +33,16 @@ defimpl Boruta.Ecto.OauthMapper, for: Boruta.Ecto.Token do _ -> nil end - resource_owner = with "" <> agent_token <- token.agent_token, - %Oauth.Token{} = token <- AgentTokens.get_by(value: agent_token), - {:ok, claims} <- AgentTokens.claims_from_agent_token(token) do - resource_owner = resource_owner || %ResourceOwner{sub: nil} - %{resource_owner | extra_claims: Map.merge(resource_owner.extra_claims, claims)} - else - _ -> - resource_owner - end + resource_owner = + with "" <> agent_token <- token.agent_token, + %Oauth.Token{} = token <- AgentTokens.get_by(value: agent_token), + {:ok, claims} <- AgentTokens.claims_from_agent_token(token) do + resource_owner = resource_owner || %ResourceOwner{sub: ResourceOwner.agent_sub()} + %{resource_owner | extra_claims: Map.merge(resource_owner.extra_claims, claims)} + else + _ -> + resource_owner + end struct( Oauth.Token, @@ -59,9 +60,9 @@ end defimpl Boruta.Ecto.OauthMapper, for: Boruta.Ecto.Client do import Boruta.Config, only: [repo: 0] - alias Boruta.Oauth alias Boruta.Ecto alias Boruta.Ecto.OauthMapper + alias Boruta.Oauth def to_oauth_schema(%Ecto.Client{} = client) do client = repo().preload(client, :authorized_scopes) diff --git a/lib/boruta/adapters/ecto/preauthorized_codes.ex b/lib/boruta/adapters/ecto/preauthorized_codes.ex index 2c72293c..b539256b 100644 --- a/lib/boruta/adapters/ecto/preauthorized_codes.ex +++ b/lib/boruta/adapters/ecto/preauthorized_codes.ex @@ -31,16 +31,25 @@ defmodule Boruta.Ecto.PreauthorizedCodes do apply(Token, changeset_method(client), [ %Token{resource_owner: resource_owner}, %{ - client_id: client_id, - sub: sub, - state: state, - nonce: params[:nonce], agent_token: params[:agent_token], - scope: scope, resource: params[:resource], - redirect_uri: redirect_uri, authorization_code_ttl: authorization_code_ttl, - authorization_details: resource_owner.authorization_details + authorization_details: resource_owner.authorization_details, + client_id: client_id, + client_encryption_key: params[:client_encryption_key], + client_encryption_alg: params[:client_encryption_alg], + code_challenge: params[:code_challenge], + code_challenge_method: params[:code_challenge_method], + nonce: params[:nonce], + presentation_definition: params[:presentation_definition], + previous_code: params[:previous_code], + public_client_id: params[:public_client_id], + redirect_uri: redirect_uri, + response_type: params[:response_type], + scope: scope, + requested_scope: params[:requested_scope] || "", + state: state, + sub: sub } ]) diff --git a/lib/boruta/adapters/ecto/schemas/client.ex b/lib/boruta/adapters/ecto/schemas/client.ex index 2f6008ed..03d1b912 100644 --- a/lib/boruta/adapters/ecto/schemas/client.ex +++ b/lib/boruta/adapters/ecto/schemas/client.ex @@ -51,7 +51,8 @@ defmodule Boruta.Ecto.Client do public_key: String.t(), private_key: String.t(), response_mode: String.t(), - signatures_adapter: String.t() + signatures_adapter: String.t(), + key_pair_type: map() } @token_endpoint_auth_methods [ @@ -405,9 +406,15 @@ defmodule Boruta.Ecto.Client do case key_pair_type do %{"type" => "universal"} -> - validate_inclusion(changeset, :signatures_adapter, [Atom.to_string(Boruta.Universal.Signatures)]) + validate_inclusion(changeset, :signatures_adapter, [ + Atom.to_string(Boruta.Universal.Signatures) + ]) + %{"type" => type} when type in ["ec", "rsa"] -> - validate_inclusion(changeset, :signatures_adapter, [Atom.to_string(Boruta.Internal.Signatures)]) + validate_inclusion(changeset, :signatures_adapter, [ + Atom.to_string(Boruta.Internal.Signatures) + ]) + _ -> add_error(changeset, :signatures_adapter, "unknown key pair type") end @@ -536,9 +543,12 @@ defmodule Boruta.Ecto.Client do Regex.match?(@resource_indicator_uri_characters, uri) end - defp valid_resource_authority?(%URI{authority: nil}), do: true - defp valid_resource_authority?(%URI{host: host}) when is_binary(host) and host != "", do: true - defp valid_resource_authority?(_uri), do: false + defp valid_resource_authority?(%URI{} = uri) do + is_nil(Map.get(uri, :authority)) or valid_resource_host?(uri) + end + + defp valid_resource_host?(%URI{host: host}) when is_binary(host) and host != "", do: true + defp valid_resource_host?(_uri), do: false defp validate_id_token_signature_alg(changeset) do signature_algorithms = Enum.map(Client.Crypto.signature_algorithms(), &Atom.to_string/1) @@ -582,9 +592,7 @@ defmodule Boruta.Ecto.Client do %{"type" => "rsa", "modulus_size" => modulus_size, "exponent_size" => exponent_size} -> case parse_rsa_modulus_size(modulus_size) do {:ok, modulus_size} when modulus_size >= @minimum_rsa_modulus_size -> - JOSE.JWK.generate_key( - {:rsa, modulus_size, String.to_integer(exponent_size)} - ) + JOSE.JWK.generate_key({:rsa, modulus_size, String.to_integer(exponent_size)}) _ -> nil diff --git a/lib/boruta/adapters/ecto/schemas/token.ex b/lib/boruta/adapters/ecto/schemas/token.ex index dfcf96ce..a866374a 100644 --- a/lib/boruta/adapters/ecto/schemas/token.ex +++ b/lib/boruta/adapters/ecto/schemas/token.ex @@ -18,12 +18,14 @@ 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(), nonce: String.t(), c_nonce: String.t(), scope: String.t(), + requested_scope: String.t() | nil, resource: String.t() | nil, redirect_uri: String.t(), expires_at: integer(), @@ -36,7 +38,9 @@ defmodule Boruta.Ecto.Token do previous_code: String.t() | nil, agent_token: String.t() | nil, bind_data: map() | nil, - bind_configuration: map() | nil + bind_configuration: map() | nil, + client_encryption_key: String.t() | nil, + client_encryption_alg: String.t() | nil } @authorization_details_schema %{ @@ -61,6 +65,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) @@ -70,6 +75,7 @@ defmodule Boruta.Ecto.Token do field(:nonce, :string) field(:c_nonce, :string) field(:scope, :string, default: "") + field(:requested_scope, :string) field(:resource, :string) field(:redirect_uri, :string) field(:expires_at, :integer) @@ -85,6 +91,9 @@ defmodule Boruta.Ecto.Token do field(:agent_token, :string) field(:bind_data, :map) field(:bind_configuration, :map) + field(:client_encryption_key, :map) + field(:client_encryption_alg, :string) + field(:metadata_policy, :map) field(:resource_owner, :map, virtual: true) @@ -105,6 +114,7 @@ defmodule Boruta.Ecto.Token do :state, :nonce, :scope, + :requested_scope, :resource, :access_token_ttl, :previous_code, @@ -132,6 +142,7 @@ defmodule Boruta.Ecto.Token do :state, :nonce, :scope, + :requested_scope, :resource, :previous_token, :previous_code, @@ -157,6 +168,7 @@ defmodule Boruta.Ecto.Token do :state, :nonce, :scope, + :requested_scope, :resource, :access_token_ttl, :previous_code, @@ -184,6 +196,7 @@ defmodule Boruta.Ecto.Token do :state, :nonce, :scope, + :requested_scope, :resource, :previous_token, :previous_code, @@ -204,6 +217,8 @@ defmodule Boruta.Ecto.Token do def preauthorized_code_changeset(token, attrs) do token |> cast(attrs, [ + :response_type, + :agent_token, :authorization_code_ttl, :client_id, :sub, @@ -212,10 +227,20 @@ defmodule Boruta.Ecto.Token do :scope, :resource, :authorization_details, + :client_id, + :client_encryption_key, + :client_encryption_alg, + :nonce, + :presentation_definition, + :previous_code, + :public_client_id, :redirect_uri, - :agent_token + :scope, + :requested_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(:preauthorized_code) @@ -227,8 +252,13 @@ 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, + :client_encryption_key, + :client_encryption_alg, :sub, :state, :nonce, @@ -236,14 +266,19 @@ defmodule Boruta.Ecto.Token do :resource, :code_challenge, :code_challenge_method, - :authorization_details, + :nonce, + :presentation_definition, + :previous_code, + :public_client_id, :redirect_uri, - :agent_token + :scope, + :requested_scope, + :state, + :sub ]) |> validate_required([ :authorization_code_ttl, :client_id, - :sub, :code_challenge ]) |> foreign_key_constraint(:client_id) @@ -259,6 +294,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, @@ -267,9 +303,13 @@ defmodule Boruta.Ecto.Token do :state, :nonce, :scope, + :requested_scope, :resource, :authorization_details, - :presentation_definition + :presentation_definition, + :client_encryption_key, + :client_encryption_alg, + :previous_code ]) |> validate_required([:authorization_code_ttl, :client_id, :sub, :redirect_uri]) |> foreign_key_constraint(:client_id) @@ -282,6 +322,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, @@ -290,11 +331,15 @@ defmodule Boruta.Ecto.Token do :state, :nonce, :scope, + :requested_scope, :resource, :code_challenge, :code_challenge_method, :authorization_details, - :presentation_definition + :presentation_definition, + :client_encryption_key, + :client_encryption_alg, + :previous_code ]) |> validate_required([ :authorization_code_ttl, @@ -312,6 +357,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() @@ -326,6 +376,15 @@ defmodule Boruta.Ecto.Token do change(token, revoked_at: now) end + @doc false + def client_encryption_changeset(token, attrs) do + token + |> cast(attrs, [ + :client_encryption_key, + :client_encryption_alg + ]) + end + defp put_value(%Ecto.Changeset{data: data, changes: changes} = changeset, type) do put_change( changeset, diff --git a/lib/boruta/adapters/ecto/stores/token_store.ex b/lib/boruta/adapters/ecto/stores/token_store.ex index 221bd27b..788ae081 100644 --- a/lib/boruta/adapters/ecto/stores/token_store.ex +++ b/lib/boruta/adapters/ecto/stores/token_store.ex @@ -54,6 +54,10 @@ defmodule Boruta.Ecto.TokenStore do cache_backend().put({Token, :value, token.value}, token, ttl: authorization_code_ttl * 1000 ), + :ok <- + cache_backend().put({Token, :id, token.id}, token, + ttl: authorization_code_ttl * 1000 + ), :ok <- cache_backend().put({Token, :refresh_token, token.refresh_token}, token, ttl: authorization_code_ttl * 1000 @@ -63,7 +67,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 44840eb7..fd1646f1 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -38,11 +38,13 @@ defmodule Boruta.Oauth.AuthorizationSuccess do resource_owner: nil, sub: nil, scope: nil, + requested_scope: nil, resource: nil, state: nil, nonce: nil, access_token: nil, code: nil, + previous_code: nil, code_challenge: nil, code_challenge_method: nil, authorization_details: nil, @@ -51,7 +53,9 @@ defmodule Boruta.Oauth.AuthorizationSuccess do response_mode: nil, agent_token: nil, bind_data: nil, - bind_configuration: nil + bind_configuration: nil, + client_encryption_key: nil, + client_encryption_alg: nil @type t :: %__MODULE__{ response_types: list(String.t()), @@ -59,10 +63,12 @@ defmodule Boruta.Oauth.AuthorizationSuccess do public_client_id: String.t(), access_token: Boruta.Oauth.Token.t() | nil, code: Boruta.Oauth.Token.t() | nil, + previous_code: Boruta.Oauth.Token.t() | nil, redirect_uri: String.t() | nil, sub: String.t() | nil, resource_owner: Boruta.Oauth.ResourceOwner.t() | nil, scope: String.t(), + requested_scope: String.t(), resource: String.t() | nil, state: String.t() | nil, nonce: String.t() | nil, @@ -74,7 +80,9 @@ defmodule Boruta.Oauth.AuthorizationSuccess do response_mode: String.t() | nil, agent_token: String.t() | nil, bind_data: map(), - bind_configuration: map() + bind_configuration: map(), + client_encryption_key: String.t() | nil, + client_encryption_alg: String.t() | nil } end @@ -180,9 +188,12 @@ defmodule Boruta.Oauth.Authorization.Resource do defp valid_characters?(resource), do: Regex.match?(@uri_characters, resource) - defp valid_authority?(%URI{authority: nil}), do: true - defp valid_authority?(%URI{host: host}) when is_binary(host) and host != "", do: true - defp valid_authority?(_uri), do: false + defp valid_authority?(%URI{} = uri) do + is_nil(Map.get(uri, :authority)) or valid_host?(uri) + end + + defp valid_host?(%URI{host: host}) when is_binary(host) and host != "", do: true + defp valid_host?(_uri), do: false defp invalid_target(error_description) do {:error, @@ -244,9 +255,9 @@ end defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AgentCredentialsRequest do alias Boruta.AgentTokensAdapter alias Boruta.Dpop + alias Boruta.Oauth.AgentCredentialsRequest alias Boruta.Oauth.Authorization alias Boruta.Oauth.AuthorizationSuccess - alias Boruta.Oauth.AgentCredentialsRequest alias Boruta.Oauth.Token def preauthorize(%AgentCredentialsRequest{ @@ -402,8 +413,9 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AuthorizationCodeRequest d client: client, code_verifier: code_verifier }), + code_chain <- CodesAdapter.code_chain(code), {:ok, resource} <- Authorization.Resource.authorize(resource, client, code.resource), - {:ok, %ResourceOwner{sub: sub}} <- + {:ok, %ResourceOwner{sub: sub} = resource_owner} <- Authorization.ResourceOwner.authorize(resource_owner: code.resource_owner) do {:ok, %AuthorizationSuccess{ @@ -411,7 +423,8 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AuthorizationCodeRequest d code: code, redirect_uri: redirect_uri, sub: sub, - scope: code.scope, + resource_owner: resource_owner, + scope: Enum.map_join(code_chain, " ", & &1.scope), resource: resource, nonce: code.nonce, authorization_details: code.authorization_details @@ -426,6 +439,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AuthorizationCodeRequest d code: code, redirect_uri: redirect_uri, sub: sub, + resource_owner: resource_owner, scope: scope, resource: resource, nonce: nonce, @@ -439,6 +453,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AuthorizationCodeRequest d redirect_uri: redirect_uri, previous_code: code.value, sub: sub, + resource_owner: resource_owner, scope: scope, resource: resource, authorization_details: authorization_details @@ -467,8 +482,8 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AgentCodeRequest do alias Boruta.AgentTokensAdapter alias Boruta.CodesAdapter alias Boruta.Dpop - alias Boruta.Oauth.Authorization alias Boruta.Oauth.AgentCodeRequest + alias Boruta.Oauth.Authorization alias Boruta.Oauth.AuthorizationSuccess alias Boruta.Oauth.Client alias Boruta.Oauth.IdToken @@ -516,6 +531,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AgentCodeRequest do code: code, redirect_uri: redirect_uri, sub: sub, + resource_owner: resource_owner, scope: code.scope, resource: resource, nonce: code.nonce, @@ -533,6 +549,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AgentCodeRequest do code: code, redirect_uri: redirect_uri, sub: sub, + resource_owner: resource_owner, scope: scope, resource: resource, nonce: nonce, @@ -548,6 +565,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AgentCodeRequest do redirect_uri: redirect_uri, previous_code: code.value, sub: sub, + resource_owner: resource_owner, scope: scope, resource: resource, authorization_details: authorization_details, @@ -575,14 +593,14 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AgentCodeRequest do end defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizationCodeRequest do - alias Boruta.Oauth.Client alias Boruta.AccessTokensAdapter alias Boruta.CodesAdapter alias Boruta.Oauth.Authorization - alias Boruta.Oauth.PreauthorizationCodeRequest alias Boruta.Oauth.AuthorizationSuccess + alias Boruta.Oauth.Client alias Boruta.Oauth.Error alias Boruta.Oauth.IdToken + alias Boruta.Oauth.PreauthorizationCodeRequest alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.Scope alias Boruta.Oauth.Token @@ -599,7 +617,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizationCodeReques {:ok, resource} <- Authorization.Resource.authorize(resource, code.client, code.resource), :ok <- maybe_check_tx_code(tx_code, code), - {:ok, %ResourceOwner{sub: sub}} <- + {:ok, %ResourceOwner{sub: sub} = resource_owner} <- (case code.agent_token do nil -> Authorization.ResourceOwner.authorize(resource_owner: code.resource_owner) @@ -612,6 +630,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizationCodeReques client: code.client, code: code, sub: sub, + resource_owner: resource_owner, scope: code.scope, resource: resource, nonce: code.nonce, @@ -627,6 +646,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizationCodeReques client: client, code: code, sub: sub, + resource_owner: resource_owner, scope: scope, resource: resource, nonce: nonce, @@ -640,6 +660,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizationCodeReques client: client, previous_code: code.value, sub: sub, + resource_owner: resource_owner, scope: scope, resource: resource, authorization_details: authorization_details, @@ -805,7 +826,8 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.TokenRequest do end defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizedCodeRequest do - alias Boruta.PreauthorizedCodesAdapter + alias Boruta.ClientsAdapter + alias Boruta.CodesAdapter alias Boruta.Oauth.Authorization alias Boruta.Oauth.AuthorizationSuccess alias Boruta.Oauth.Client @@ -814,51 +836,60 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PreauthorizedCodeRequest d alias Boruta.Oauth.PreauthorizedCodeRequest alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.Token + alias Boruta.PreauthorizedCodesAdapter def preauthorize(%PreauthorizedCodeRequest{ agent_token: agent_token, client_id: client_id, redirect_uri: redirect_uri, resource_owner: resource_owner, + code: previous_code, state: state, scope: scope, resource: resource, 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, scope} <- - Authorization.Scope.authorize( + Authorization.Scope.filter( scope: scope, - against: %{client: client, resource_owner: resource_owner} + against: %{client: client, resource_owner: resource_owner, code: code} + ), + {:ok, %ResourceOwner{sub: sub} = resource_owner} <- + Authorization.AgentToken.authorize( + agent_token: (code && code.agent_token) || agent_token, + resource_owner: resource_owner ), {:ok, resource} <- Authorization.Resource.authorize(resource, client) do {:ok, %AuthorizationSuccess{ client: client, redirect_uri: redirect_uri, + code: code, sub: sub, scope: scope, resource: resource, state: state, resource_owner: resource_owner, - agent_token: agent_token + agent_token: agent_token || (code && code.agent_token), + authorization_details: resource_owner.authorization_details }} else error -> @@ -872,26 +903,31 @@ 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, resource: resource, 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, resource: resource, state: state, nonce: nonce, - agent_token: agent_token + agent_token: agent_token, + authorization_details: authorization_details }) do {:ok, %{preauthorized_code: preauthorized_code}} end @@ -989,6 +1025,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, @@ -1104,79 +1141,92 @@ end defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do alias Boruta.ClientsAdapter - alias Boruta.CodesAdapter alias Boruta.Oauth.Authorization alias Boruta.Oauth.AuthorizationSuccess alias Boruta.Oauth.CodeRequest alias Boruta.Oauth.Error alias Boruta.Oauth.PresentationRequest + alias Boruta.Oauth.ResourceOwner + alias Boruta.Oauth.Scope alias Boruta.Oauth.Token alias Boruta.Openid.VerifiableCredentials alias Boruta.Openid.VerifiablePresentations + alias Boruta.PreauthorizedCodesAdapter 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: %ResourceOwner{} = resource_owner, + response_type: response_type, + scope: requested_scope, + state: state, + agent_token: agent_token, + client_encryption_key: client_encryption_key, + client_encryption_alg: client_encryption_alg } = request ) do - with [response_type] = response_types <- + with response_types <- VerifiablePresentations.response_types( response_type, - scope, + requested_scope, resource_owner.presentation_configuration ), - {:ok, client} <- - (case client_id do - "did:" <> _key -> - {:ok, ClientsAdapter.public!()} - - _ -> - Authorization.Client.authorize( - id: client_id, - source: nil, - redirect_uri: redirect_uri, - grant_type: response_type - ) - end), + {:ok, client} <- authorize_presentation_client(client_id, redirect_uri, response_types), + {:ok, _code} <- + authorize_optional_code(code), :ok <- Authorization.Nonce.authorize(request), :ok <- VerifiableCredentials.validate_authorization_details(authorization_details), + {:ok, previous_code} <- authorize_optional_code(code), :ok <- VerifiablePresentations.check_client_metadata(client_metadata), - presentation_definition <- + {:ok, identifier, presentation_definition} <- VerifiablePresentations.presentation_definition( + response_types, resource_owner.presentation_configuration, - scope - ) do + requested_scope + ), + {:ok, resource_owner} <- authorize_presentation_agent_token(agent_token, resource_owner), + {:ok, scope} <- + Authorization.Scope.filter( + scope: requested_scope, + against: %{ + client: client, + resource_owner: resource_owner, + presentation_scopes: [identifier] + } + ), + requested_scope <- Enum.join(Scope.split(requested_scope) -- Scope.split(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_params(resource_owner, code_challenge, code_challenge_method) {: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, + resource_owner: resource_owner, scope: scope, + requested_scope: requested_scope, state: state, nonce: nonce, + code: code, + previous_code: previous_code, code_challenge: code_challenge, code_challenge_method: code_challenge_method, - authorization_details: Jason.decode!(authorization_details), - response_mode: client.response_mode + response_mode: client.response_mode, + presentation_definition: presentation_definition, + public_client_id: client_id, + redirect_uri: redirect_uri, + response_types: response_types, + agent_token: agent_token, + client_encryption_key: client_encryption_key, + client_encryption_alg: client_encryption_alg }} else error -> @@ -1187,45 +1237,104 @@ 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, + resource_owner: resource_owner, scope: scope, + requested_scope: requested_scope, state: state, nonce: nonce, + code: code, + previous_code: previous_code, + agent_token: agent_token, code_challenge: code_challenge, code_challenge_method: code_challenge_method, - authorization_details: authorization_details, - response_mode: response_mode + presentation_definition: presentation_definition, + public_client_id: public_client_id, + redirect_uri: redirect_uri, + response_mode: response_mode, + response_types: response_types, + client_encryption_key: client_encryption_key, + client_encryption_alg: client_encryption_alg }} <- - preauthorize(request) do + Authorization.preauthorize(request) do with {:ok, code} <- - CodesAdapter.create(%{ + PreauthorizedCodesAdapter.create(%{ + sub: sub, + resource_owner: resource_owner, + 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, + requested_scope: requested_scope, state: state, nonce: nonce, + agent_token: (previous_code && previous_code.agent_token) || agent_token, code_challenge: code_challenge, code_challenge_method: code_challenge_method, authorization_details: authorization_details, - presentation_definition: presentation_definition + presentation_definition: presentation_definition, + client_encryption_key: + (previous_code && previous_code.client_encryption_key) || client_encryption_key, + client_encryption_alg: + (previous_code && previous_code.client_encryption_alg) || client_encryption_alg }) do - case response_types do - ["id_token"] -> + case verifiable_presentation?(response_types) do + false -> {:ok, %{siopv2_code: code, response_mode: response_mode}} - ["vp_token"] -> + true -> {:ok, %{vp_code: code, response_mode: response_mode}} end end end end + + defp authorize_presentation_client("did:" <> _key, _redirect_uri, _response_types) do + {:ok, ClientsAdapter.public!()} + end + + defp authorize_presentation_client(client_id, redirect_uri, response_types) do + Authorization.Client.authorize( + id: client_id, + source: nil, + redirect_uri: redirect_uri, + grant_type: List.first(response_types) + ) + end + + defp authorize_optional_code(nil), do: {:ok, nil} + defp authorize_optional_code(code), do: Authorization.Code.authorize(%{value: code}) + + defp authorize_presentation_agent_token(nil, resource_owner) do + Authorization.ResourceOwner.authorize(resource_owner: resource_owner) + end + + defp authorize_presentation_agent_token(agent_token, resource_owner) do + Authorization.AgentToken.authorize( + agent_token: agent_token, + resource_owner: resource_owner + ) + end + + defp code_challenge_params(%{code_verifier: nil}, code_challenge, code_challenge_method) do + {code_challenge, code_challenge_method} + end + + defp code_challenge_params( + %{code_verifier: code_verifier}, + _code_challenge, + _code_challenge_method + ) do + {code_verifier, "plain"} + end + + defp verifiable_presentation?(["code" | _response_types]), do: false + defp verifiable_presentation?(["id_token" | _response_types]), do: false + defp verifiable_presentation?(["vp_token" | _response_types]), do: true end defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.HybridRequest do @@ -1270,6 +1379,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/authorization/nonce.ex b/lib/boruta/oauth/authorization/nonce.ex index c8170bd7..7dca05c5 100644 --- a/lib/boruta/oauth/authorization/nonce.ex +++ b/lib/boruta/oauth/authorization/nonce.ex @@ -63,8 +63,8 @@ defimpl Boruta.Oauth.Authorization.Nonce, for: Boruta.Oauth.CodeRequest do end defimpl Boruta.Oauth.Authorization.Nonce, for: Boruta.Oauth.TokenRequest do - alias Boruta.Oauth.TokenRequest alias Boruta.Oauth.Error + alias Boruta.Oauth.TokenRequest def authorize(%Boruta.Oauth.TokenRequest{nonce: nonce} = request) do case {TokenRequest.require_nonce?(request), nonce} do diff --git a/lib/boruta/oauth/authorization/scope.ex b/lib/boruta/oauth/authorization/scope.ex index 8a209200..6ccec2e4 100644 --- a/lib/boruta/oauth/authorization/scope.ex +++ b/lib/boruta/oauth/authorization/scope.ex @@ -10,7 +10,7 @@ defmodule Boruta.Oauth.Authorization.Scope do alias Boruta.ScopesAdapter @doc """ - Authorize the given scope according to the given client. + Authorize the given scope according to the given constraints. ## Examples iex> authorize(%{scope: "scope", client: %Client{...}}) @@ -57,6 +57,41 @@ defmodule Boruta.Oauth.Authorization.Scope do end end + @doc """ + Filter the given scope according to the given constraints. + + ## Examples + iex> filter(%{scope: "scope", client: %Client{...}}) + {:ok, "scope"} + """ + @spec filter( + params :: [ + scope: String.t(), + against: %{ + optional(:client) => %Client{}, + optional(:resource_owner) => struct(), + optional(:token) => %Token{} + } + ] + ) :: + {:ok, scope :: String.t()} + def filter(scope: nil, against: _against), do: {:ok, ""} + + def filter(scope: "" <> scope, against: against) do + scopes = Scope.split(scope) + + public_scopes = + ScopesAdapter.public() + |> List.insert_at(0, Scope.openid()) + |> Enum.map(fn scope -> scope.name end) + + against = Map.put(against, :public, public_scopes) + + authorized_scopes = authorized_scopes(scopes, against) + + {:ok, Enum.join(scopes -- (scopes -- authorized_scopes), " ")} + end + defp authorized_scopes(scopes, against) do against |> Enum.reduce([], fn diff --git a/lib/boruta/oauth/contexts/codes.ex b/lib/boruta/oauth/contexts/codes.ex index 211c2728..f4572584 100644 --- a/lib/boruta/oauth/contexts/codes.ex +++ b/lib/boruta/oauth/contexts/codes.ex @@ -6,43 +6,62 @@ 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() - }) :: {:ok, 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() + } + ) :: {:ok, 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. + Updates code client encryption """ - @callback revoke_previous_token( - token :: Boruta.Oauth.Token.t() - ) :: {:ok, Boruta.Oauth.Token.t()} | {:error, reason :: term()} + @callback update_client_encryption( + token :: Boruta.Oauth.Token.t(), + params :: map() + ) :: {:ok, Boruta.Oauth.Token.t()} | {:error, reason :: term()} + + @doc """ + 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()} + + @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/error.ex b/lib/boruta/oauth/error.ex index ff04fd6b..492412f4 100644 --- a/lib/boruta/oauth/error.ex +++ b/lib/boruta/oauth/error.ex @@ -141,7 +141,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(%{response_mode: "query"}), do: :query diff --git a/lib/boruta/oauth/json/schema.ex b/lib/boruta/oauth/json/schema.ex index d4fa33b3..a2d0d16f 100644 --- a/lib/boruta/oauth/json/schema.ex +++ b/lib/boruta/oauth/json/schema.ex @@ -178,13 +178,13 @@ defmodule Boruta.Oauth.Json.Schema do "type" => "string", "pattern" => @uuid_pattern }, + "encrypted_request" => %{"type" => "string"}, "state" => %{"type" => "string"}, "nonce" => %{"type" => "string"}, "redirect_uri" => %{"type" => "string"}, "resource" => %{"type" => "string"}, "prompt" => %{"type" => "string"} - }, - "required" => ["response_type", "client_id", "redirect_uri"] + } } |> Schema.resolve() end @@ -248,10 +248,7 @@ defmodule Boruta.Oauth.Json.Schema do "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 4186f1e8..c85611ce 100644 --- a/lib/boruta/oauth/request/base.ex +++ b/lib/boruta/oauth/request/base.ex @@ -112,6 +112,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"], @@ -134,30 +135,21 @@ 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 - request = %PresentationRequest{ - client_id: params["client_id"], - resource_owner: params["resource_owner"], - redirect_uri: params["redirect_uri"], - state: params["state"], - nonce: params["nonce"], - prompt: params["prompt"], - code_challenge: params["code_challenge"], - code_challenge_method: params["code_challenge_method"], - scope: params["scope"], - client_metadata: client_metadata, - response_type: params["response_type"] - } + %{"response_type" => "code" <> _rest, "client_metadata" => _client_metadata} = params + ) do + presentation_request(params) + end - request = - case params["authorization_details"] do - nil -> request - authorization_details -> %{request | authorization_details: authorization_details} - end + def build_request( + %{"response_type" => "id_token" <> _rest, "client_metadata" => _client_metadata} = params + ) do + presentation_request(params) + end - {:ok, request} + def build_request( + %{"response_type" => "vp_token" <> _rest, "client_metadata" => _client_metadata} = params + ) do + presentation_request(params) end def build_request(%{"response_type" => "code", "method" => "POST"} = params) do @@ -264,7 +256,37 @@ defmodule Boruta.Oauth.Request.Base do }} end - def fetch_unsigned_request(%{query_params: %{"request" => request}}) do + defp presentation_request( + %{"response_type" => response_type, "client_metadata" => client_metadata} = params + ) do + request = %PresentationRequest{ + client_id: params["client_id"], + resource_owner: params["resource_owner"], + redirect_uri: params["redirect_uri"], + state: params["state"], + nonce: params["nonce"], + prompt: params["prompt"], + code_challenge: params["code_challenge"], + code_challenge_method: params["code_challenge_method"], + code: params["code"], + agent_token: params["agent_token"], + scope: params["scope"], + client_metadata: client_metadata, + response_type: response_type, + client_encryption_key: params["client_encryption_key"], + client_encryption_alg: params["client_encryption_alg"] + } + + request = + case params["authorization_details"] do + nil -> request + authorization_details -> %{request | authorization_details: authorization_details} + end + + {:ok, request} + end + + def fetch_unsigned_request(%{query_params: %{"request" => request}}) when is_binary(request) do case Joken.peek_claims(request) do {:ok, params} -> {:ok, params} diff --git a/lib/boruta/oauth/request/token.ex b/lib/boruta/oauth/request/token.ex index 42853610..b868b434 100644 --- a/lib/boruta/oauth/request/token.ex +++ b/lib/boruta/oauth/request/token.ex @@ -3,6 +3,8 @@ defmodule Boruta.Oauth.Request.Token do import Boruta.Oauth.Request.Base + alias Boruta.ClientsAdapter + alias Boruta.Oauth alias Boruta.Oauth.AuthorizationCodeRequest alias Boruta.Oauth.ClientCredentialsRequest alias Boruta.Oauth.Error @@ -24,7 +26,8 @@ defmodule Boruta.Oauth.Request.Token do | ClientCredentialsRequest.t() | PasswordRequest.t()} def request(%{body_params: body_params} = request) do - with {:ok, unsigned_params} <- fetch_unsigned_request(request), + with {:ok, body_params} <- decrypt_request(body_params), + {:ok, unsigned_params} <- fetch_unsigned_request(request), {:ok, client_authentication_params} <- fetch_client_authentication(request), {:ok, dpop} <- fetch_dpop(request), {:ok, params} <- @@ -47,6 +50,18 @@ defmodule Boruta.Oauth.Request.Token do end end + def decrypt_request(%{"client_id" => client_id, "encrypted_request" => request} = params) when is_binary(request) do + with %Oauth.Client{} = client <- ClientsAdapter.get_client(client_id), + {:ok, request_params} <- Oauth.Client.Crypto.decrypt(request, client) do + {:ok, Map.merge(params, request_params)} + else + _ -> + {:ok, params} + end + end + + def decrypt_request(params), do: {:ok, params} + defp fetch_dpop(%{req_headers: req_headers}) do with {"dpop", dpop} <- List.keyfind(req_headers, "dpop", 0), nil <- List.keyfind(req_headers, "dpop", 1) do diff --git a/lib/boruta/oauth/requests/preauthorized_code.ex b/lib/boruta/oauth/requests/preauthorized_code.ex index f0696db3..17c081d8 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(), @@ -23,6 +24,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..64b38be9 100644 --- a/lib/boruta/oauth/requests/presentation_request.ex +++ b/lib/boruta/oauth/requests/presentation_request.ex @@ -8,6 +8,8 @@ defmodule Boruta.Oauth.PresentationRequest do """ @type t :: %__MODULE__{ client_id: String.t(), + code: String.t() | nil, + agent_token: String.t() | nil, resource_owner: Boruta.Oauth.ResourceOwner.t(), redirect_uri: String.t(), state: String.t(), @@ -19,11 +21,15 @@ defmodule Boruta.Oauth.PresentationRequest do code_challenge_method: String.t(), response_type: String.t(), client_metadata: String.t(), - authorization_details: String.t() + authorization_details: String.t(), + client_encryption_key: map() | nil, + client_encryption_alg: String.t() | nil } @enforce_keys [:client_id, :redirect_uri] defstruct client_id: nil, + code: nil, + agent_token: nil, resource_owner: nil, redirect_uri: nil, state: "", @@ -35,5 +41,7 @@ defmodule Boruta.Oauth.PresentationRequest do code_challenge: "", code_challenge_method: "plain", authorization_details: "[]", - client_metadata: "{}" + client_metadata: "{}", + client_encryption_key: nil, + client_encryption_alg: nil end diff --git a/lib/boruta/oauth/responses/authorize.ex b/lib/boruta/oauth/responses/authorize.ex index 65b4019a..76087035 100644 --- a/lib/boruta/oauth/responses/authorize.ex +++ b/lib/boruta/oauth/responses/authorize.ex @@ -17,8 +17,8 @@ defmodule Boruta.Oauth.AuthorizeResponse do type: nil @type t :: %__MODULE__{ - access_token: String.t() | nil, - code: String.t() | nil, + access_token: Boruta.Oauth.Token.t() | nil, + code: Boruta.Oauth.Token.t() | nil, code_challenge: String.t() | nil, code_challenge_method: String.t() | nil, expires_in: integer(), @@ -56,12 +56,11 @@ defmodule Boruta.Oauth.AuthorizeResponse do %{ code: %Token{ expires_at: expires_at, - value: value, redirect_uri: redirect_uri, state: state, code_challenge: code_challenge, code_challenge_method: code_challenge_method - } + } = code } = params, request ) do @@ -79,9 +78,9 @@ defmodule Boruta.Oauth.AuthorizeResponse do %AuthorizeResponse{ type: type, redirect_uri: redirect_uri, - code: value, + code: code, id_token: params[:id_token] && params[:id_token].value, - access_token: params[:token] && params[:token].value, + access_token: params[:token], expires_in: expires_in, state: state, code_challenge: code_challenge, @@ -95,10 +94,9 @@ defmodule Boruta.Oauth.AuthorizeResponse do %{ token: %Token{ expires_at: expires_at, - value: value, redirect_uri: redirect_uri, state: state - } + } = token } = params, request ) do @@ -108,7 +106,7 @@ defmodule Boruta.Oauth.AuthorizeResponse do %AuthorizeResponse{ type: :token, redirect_uri: redirect_uri, - access_token: value, + access_token: token, id_token: params[:id_token] && params[:id_token].value, expires_in: expires_in, state: state, @@ -200,9 +198,9 @@ defmodule Boruta.Oauth.AuthorizeResponse do token_type: token_type }) do %{ - code: code, + code: code && code.value, id_token: id_token, - access_token: access_token, + access_token: access_token && access_token.value, expires_in: expires_in, state: state, token_type: token_type diff --git a/lib/boruta/oauth/schemas/client.ex b/lib/boruta/oauth/schemas/client.ex index 175e1413..1f02df8b 100644 --- a/lib/boruta/oauth/schemas/client.ex +++ b/lib/boruta/oauth/schemas/client.ex @@ -47,7 +47,9 @@ defmodule Boruta.Oauth.Client do logo_uri: nil, response_mode: nil, metadata: %{}, - signatures_adapter: nil + signatures_adapter: nil, + key_pair_type: nil, + metadata_policies: [] @type t :: %__MODULE__{ id: any(), @@ -85,12 +87,15 @@ defmodule Boruta.Oauth.Client do logo_uri: String.t() | nil, response_mode: String.t(), metadata: map(), - signatures_adapter: String.t() + signatures_adapter: String.t(), + key_pair_type: map(), + metadata_policies: list(map()) } @wallet_grant_types [ "id_token", "vp_token", + "preauthorized_code", "authorization_code", "agent_credentials" ] @@ -101,7 +106,6 @@ defmodule Boruta.Oauth.Client do "password", "authorization_code", "agent_code", - "preauthorized_code", "refresh_token", "implicit", "revoke", @@ -300,6 +304,48 @@ defmodule Boruta.Oauth.Client do end @spec userinfo_signature_type(Client.t()) :: userinfo_token_signature_type :: atom() - def userinfo_signature_type(client), do: Client.signatures_adapter(client).userinfo_signature_type(client) + def userinfo_signature_type(client), + do: Client.signatures_adapter(client).userinfo_signature_type(client) + + @spec encryption_alg(client :: Client.t()) :: encryption_alg :: String.t() + def encryption_alg(%Client{key_pair_type: %{"type" => "ec"}}), do: "ECDH-ES" + + def encryption_alg(%Client{key_pair_type: %{"type" => "rsa"}}), do: "RSA-OAEP" + + @spec encrypt( + claims :: map(), + client_encryption_key :: map(), + client_encryption_alg :: String.t() + ) :: encrypted :: String.t() + def encrypt(claims, client_encryption_key, client_encryption_alg) do + sk = JOSE.JWK.generate_key({:ec, "P-256"}) + + with {:ok, payload} <- Jason.encode(claims) do + jwe = %{ + "enc" => "A256GCM", + "alg" => client_encryption_alg + } + + pk = JOSE.JWK.from_map(client_encryption_key) + + JOSE.JWE.block_encrypt({pk, sk}, payload, jwe) + |> JOSE.JWE.compact() + |> elem(1) + end + end + + @spec decrypt(encrypted :: String.t(), client :: Client.t()) :: + {:ok, map()} | {:error, reason :: String.t()} + def decrypt(encrypted, client) do + private_key = JOSE.JWK.from_pem(client.private_key) + + with {"" <> decrypted, _} <- JOSE.JWE.block_decrypt(private_key, encrypted), + {:ok, claims} <- Jason.decode(decrypted) do + {:ok, claims} + else + {:error, _} -> + {:error, "Could not decrypt the given payload."} + end + end end end diff --git a/lib/boruta/oauth/schemas/resource_owner.ex b/lib/boruta/oauth/schemas/resource_owner.ex index c14964bf..89703b86 100644 --- a/lib/boruta/oauth/schemas/resource_owner.ex +++ b/lib/boruta/oauth/schemas/resource_owner.ex @@ -33,10 +33,16 @@ defmodule Boruta.Oauth.ResourceOwner do types: list(String.t()), format: list(String.t()), time_to_live: integer(), - claims: list(String.t() | %{ - String.t() => String.t() - }) + claims: + list( + String.t() + | %{ + String.t() => String.t() + } + ) } } } + + def agent_sub, do: "from_agent_token" end diff --git a/lib/boruta/oauth/schemas/scope/authorize.ex b/lib/boruta/oauth/schemas/scope/authorize.ex index 08b1876b..31d17d6d 100644 --- a/lib/boruta/oauth/schemas/scope/authorize.ex +++ b/lib/boruta/oauth/schemas/scope/authorize.ex @@ -4,13 +4,22 @@ defprotocol Boruta.Oauth.Scope.Authorize do def authorized_scopes(schema, scope, public_scopes) end +defimpl Boruta.Oauth.Scope.Authorize, for: Atom do + import Boruta.Config, only: [resource_owners: 0] + + alias Boruta.Oauth.ResourceOwner + + def authorized_scopes(_authorized_scopes, _scopes, _public_scopes), do: [] +end + defimpl Boruta.Oauth.Scope.Authorize, for: List do import Boruta.Config, only: [resource_owners: 0] alias Boruta.Oauth.ResourceOwner def authorized_scopes(authorized_scopes, scopes, _public_scopes) do - scopes -- (scopes -- authorized_scopes) # intersection + # intersection + scopes -- (scopes -- authorized_scopes) end end @@ -19,11 +28,17 @@ 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 + # intersection + scopes -- (scopes -- public_scopes) + 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) - scopes -- (scopes -- authorized_scopes) # intersection + # intersection + scopes -- (scopes -- authorized_scopes) end end @@ -35,7 +50,8 @@ defimpl Boruta.Oauth.Scope.Authorize, for: Boruta.Oauth.Client do def authorized_scopes(client, scope, public_scopes) def authorized_scopes(%Client{authorize_scope: false}, scopes, public_scopes) do - scopes -- (scopes -- public_scopes) # intersection + # intersection + scopes -- (scopes -- public_scopes) end def authorized_scopes(%Client{authorize_scope: true} = client, scopes, _public_scopes) do @@ -45,17 +61,29 @@ defimpl Boruta.Oauth.Scope.Authorize, for: Boruta.Oauth.Client do fn %Scope{name: name} -> name end ) - scopes -- (scopes -- authorized_scopes) # intersection + # intersection + scopes -- (scopes -- authorized_scopes) end end defimpl Boruta.Oauth.Scope.Authorize, for: Boruta.Oauth.Token do + alias Boruta.CodesAdapter alias Boruta.Oauth.Scope alias Boruta.Oauth.Token + def authorized_scopes(%Token{type: type, scope: token_scope} = code, scopes, _public_scopes) + when type in ["preauthorized_code", "code"] do + authorized_scopes = Scope.split(token_scope) + + # intersection + (scopes -- (scopes -- authorized_scopes)) ++ + (CodesAdapter.code_chain(code) |> Enum.flat_map(fn code -> Scope.split(code.scope) end)) + end + def authorized_scopes(%Token{scope: token_scope}, scopes, _public_scopes) do authorized_scopes = Scope.split(token_scope) - scopes -- (scopes -- authorized_scopes) # intersection + # intersection + scopes -- (scopes -- authorized_scopes) end end diff --git a/lib/boruta/oauth/schemas/token.ex b/lib/boruta/oauth/schemas/token.ex index 5dc4fc39..ced609f5 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, @@ -22,6 +23,7 @@ defmodule Boruta.Oauth.Token do nonce: nil, c_nonce: nil, scope: nil, + requested_scope: nil, resource: nil, redirect_uri: nil, expires_at: nil, @@ -39,12 +41,16 @@ defmodule Boruta.Oauth.Token do previous_code: nil, bind_data: nil, bind_configuration: nil, - agent_token: nil + agent_token: nil, + client_encryption_key: nil, + client_encryption_alg: 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, @@ -53,6 +59,7 @@ defmodule Boruta.Oauth.Token do nonce: String.t() | nil, c_nonce: String.t() | nil, scope: String.t(), + requested_scope: String.t() | nil, resource: String.t() | nil, redirect_uri: String.t() | nil, expires_at: integer() | nil, @@ -70,7 +77,10 @@ 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, + client_encryption_key: String.t() | nil, + client_encryption_alg: String.t() | nil, + metadata_policy: map() } @doc """ diff --git a/lib/boruta/oauth/validator.ex b/lib/boruta/oauth/validator.ex index 988557d7..44972d51 100644 --- a/lib/boruta/oauth/validator.ex +++ b/lib/boruta/oauth/validator.ex @@ -27,7 +27,14 @@ defmodule Boruta.Oauth.Validator do @spec validate(action :: :token | :authorize | :introspect | :revoke, params :: map()) :: {:ok, params :: map()} | {:error, message :: String.t()} def validate(:token, %{"grant_type" => grant_type} = params) - when grant_type in ["password", "client_credentials", "agent_credentials", "agent_code", "authorization_code", "refresh_token"] do + when grant_type in [ + "password", + "client_credentials", + "agent_credentials", + "agent_code", + "authorization_code", + "refresh_token" + ] do case ExJsonSchema.Validator.validate( apply(Schema, String.to_atom(grant_type), []), params, @@ -41,7 +48,10 @@ defmodule Boruta.Oauth.Validator do end end - def validate(:token, %{"grant_type" => "urn:ietf:params:oauth:grant-type:pre-authorized_code"} = params) do + def validate( + :token, + %{"grant_type" => "urn:ietf:params:oauth:grant-type:pre-authorized_code"} = params + ) do case ExJsonSchema.Validator.validate( Schema.preauthorization_code(), params, @@ -66,27 +76,50 @@ defmodule Boruta.Oauth.Validator do end end - def validate(:authorize, %{"response_type" => response_types} = params) - when response_types in [ - "token", - "vp_token", - "id_token", - "id_token token", - "code", - "code id_token", - "code token", - "code id_token token" - ] do + def validate(:authorize, %{"response_type" => "token"} = params) do + case validate_multiple_response_types(params) do + :ok -> + {:ok, params} + + {:error, errors} -> + authorize_error(errors) + end + end + + def validate(:authorize, %{"response_type" => "vp_token" <> _rest} = params) do + case validate_multiple_response_types(params) do + :ok -> + {:ok, params} + + {:error, errors} -> + authorize_error(errors) + end + end + + def validate(:authorize, %{"response_type" => "id_token" <> _rest} = params) do + case validate_multiple_response_types(params) do + :ok -> + {:ok, params} + + {:error, errors} -> + authorize_error(errors) + end + end + + def validate(:authorize, %{"response_type" => "code" <> _rest} = params) do case validate_multiple_response_types(params) do :ok -> {:ok, params} {:error, errors} -> - {:error, "Query params validation failed. " <> Enum.join(errors, " ")} + authorize_error(errors) end end - def validate(:authorize, %{"response_type" => "urn:ietf:params:oauth:response-type:pre-authorized_code"} = params) do + def validate( + :authorize, + %{"response_type" => "urn:ietf:params:oauth:response-type:pre-authorized_code"} = params + ) do case ExJsonSchema.Validator.validate( Schema.preauthorized_code(), params, @@ -96,13 +129,18 @@ defmodule Boruta.Oauth.Validator do {:ok, params} {:error, errors} -> - {:error, "Query params validation failed. " <> Enum.join(errors, " ")} + authorize_error(errors) end end - 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."} + def validate(:authorize, %{"response_type" => _response_types} = params) do + case validate_multiple_response_types(params) do + :ok -> + {:ok, params} + + {:error, errors} -> + authorize_error(errors) + end end def validate(:introspect, params) do @@ -118,7 +156,9 @@ defmodule Boruta.Oauth.Validator do end def validate(:revoke, params) do - case ExJsonSchema.Validator.validate(Schema.revoke(), params, error_formatter: BorutaFormatter) do + case ExJsonSchema.Validator.validate(Schema.revoke(), params, + error_formatter: BorutaFormatter + ) do :ok -> {:ok, params} @@ -135,17 +175,42 @@ defmodule Boruta.Oauth.Validator do {:error, "Request is not a valid OAuth request. Need a response_type param."} end + defp authorize_error(["Invalid response_type param."]) do + {:error, "Invalid response_type param."} + end + + defp authorize_error(errors) do + {:error, "Query params validation failed. " <> Enum.join(errors, " ")} + end + defp validate_multiple_response_types(%{"response_type" => response_types} = params) do + response_type_schemas = %{ + "code" => :code, + "id_token" => :id_token, + "token" => :token, + "vp_token" => :vp_token + } + 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), []), - params, - error_formatter: BorutaFormatter - ) do - :ok -> {:cont, :ok} - {:error, errors} -> {:halt, {:error, errors}} + case Map.fetch(response_type_schemas, response_type) do + {:ok, schema_name} -> + case ExJsonSchema.Validator.validate( + apply(Schema, schema_name, []), + params, + error_formatter: BorutaFormatter + ) do + :ok -> {:cont, :ok} + {:error, errors} -> {:halt, {:error, errors}} + end + + :error -> + {:halt, {:error, ["Invalid response_type param."]}} end end) end diff --git a/lib/boruta/openid.ex b/lib/boruta/openid.ex index c776cfac..fa981b2d 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. > @@ -73,14 +75,31 @@ defmodule Boruta.Openid do def credential(conn, credential_params, default_credential_configuration, module) do with {:ok, access_token} <- BearerToken.extract_token(conn), {:ok, token} <- AccessToken.authorize(value: access_token), + {:ok, credential_params} <- + (case credential_params["encrypted_request"] do + nil -> + {:ok, credential_params} + + encrypted_request -> + with {:ok, params} <- Client.Crypto.decrypt(encrypted_request, token.client) do + {:ok, Map.merge(credential_params, params)} + end + end), {: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 + default_credential_configuration, + code_chain + ), + {: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 +125,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, @@ -139,37 +167,76 @@ defmodule Boruta.Openid do end @type direct_post_params :: %{ - code_id: String.t(), - code_verifier: String.t() | nil, - id_token: nil | String.t(), - vp_token: nil | String.t(), - presentation_submission: nil | String.t() + :code_id => String.t(), + optional(:code_verifier) => String.t() | nil, + optional(:presentation_submission) => String.t() | nil, + optional(:id_token) => String.t() | nil, + optional(:vp_token) => String.t() | nil, + optional(:response) => String.t() | nil } @spec direct_post( conn :: Plug.Conn.t(), direct_post_params :: direct_post_params(), module :: atom() ) :: any() + def direct_post(conn, %{code_id: code_id, response: response}, module) + when not is_nil(response) do + with %Token{} = code <- CodesAdapter.get_by(id: code_id), + {:ok, response_claims} <- Client.Crypto.decrypt(response, code.client), + direct_post_params <- %{ + code_id: code_id, + id_token: response_claims["id_token"], + vp_token: response_claims["vp_token"], + presentation_submission: response_claims["presentation_submission"] + } do + direct_post(conn, direct_post_params, module) + end + end + 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, %Token{value: value}} <- + CodesAdapter.update_sub(code, kid, claims["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, code} <- + CodesAdapter.update_client_encryption(code, %{ + client_encryption_key: claims["client_encryption_key"], + client_encryption_alg: claims["client_encryption_alg"] + }), + {: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 + state: code.state, + client_encryption_key: claims["client_encryption_key"], + client_encryption_alg: claims["client_encryption_alg"] }) 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 +247,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 +272,117 @@ defmodule Boruta.Openid do end end - defp check_id_token_client(%{id_token: id_token}) do - case VerifiableCredentials.validate_signature(id_token) do + defp check_client_metadata_policy([_current | 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, + code_chain -- acc + ) 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, _code_chain), do: :ok + + defp do_check_client_metadata_policy( + params, + %{ + "client_id" => %{"one_of" => client_ids} = client_id_constraints + } = constraints, + code_chain + ) do + with {:ok, chain_client_ids} <- metadata_policy_client_ids(params, code_chain), + true <- Enum.any?(chain_client_ids, &Enum.member?(client_ids, &1)) do + do_check_client_metadata_policy( + params, + Map.put(constraints, "client_id", Map.delete(client_id_constraints, "one_of")), + code_chain + ) + else + _error -> + {:error, "Metadata policies check failed."} + end + end + + defp do_check_client_metadata_policy( + params, + %{ + "client_id" => %{"superset_of" => client_ids} = client_id_constraints + } = constraints, + code_chain + ) do + with {:ok, chain_client_ids} <- metadata_policy_client_ids(params, code_chain), + true <- Enum.all?(client_ids, &Enum.member?(chain_client_ids, &1)) do + do_check_client_metadata_policy( + params, + Map.put(constraints, "client_id", Map.delete(client_id_constraints, "superset_of")), + code_chain + ) + else + _error -> + {:error, "Metadata policies check failed."} + end + end + + defp do_check_client_metadata_policy(_params, _constraints, _code_chain), do: :ok + + defp metadata_policy_client_ids(params, code_chain) do + with {:ok, kid} <- metadata_policy_kid(params) do + {:ok, [kid | Enum.map(code_chain, & &1.sub)]} + end + end + + defp metadata_policy_kid(params) do + with {:ok, jwt} <- metadata_policy_jwt(params), + {:ok, headers} <- Joken.peek_header(jwt) do + {:ok, client_id_from_headers(headers)} + end + end + + defp metadata_policy_jwt(%{"proof" => %{"proof_type" => "jwt", "jwt" => jwt}}) + when is_binary(jwt), + do: {:ok, jwt} + + defp metadata_policy_jwt(%{proof: %{"proof_type" => "jwt", "jwt" => jwt}}) + when is_binary(jwt), + do: {:ok, jwt} + + defp metadata_policy_jwt(%{"id_token" => jwt}) when is_binary(jwt), do: {:ok, jwt} + defp metadata_policy_jwt(%{id_token: jwt}) when is_binary(jwt), do: {:ok, jwt} + defp metadata_policy_jwt(%{"vp_token" => jwt}) when is_binary(jwt), do: {:ok, jwt} + defp metadata_policy_jwt(%{vp_token: jwt}) when is_binary(jwt), do: {:ok, jwt} + defp metadata_policy_jwt(_), do: {:error, :jwt_not_found} + + defp check_id_token_client(%{vp_token: vp_token}) when not is_nil(vp_token) do + case VerifiablePresentations.validate_signature(vp_token) do {:ok, _jwk, claims} -> - {:ok, claims} + {:ok, headers} = Joken.peek_header(vp_token) + {:ok, client_id_from_headers(headers), claims} {:error, error} -> {:error, @@ -212,10 +394,11 @@ defmodule Boruta.Openid do end end - defp check_id_token_client(%{vp_token: vp_token}) do - case VerifiablePresentations.validate_signature(vp_token) do + defp check_id_token_client(%{id_token: id_token}) when not is_nil(id_token) do + case VerifiableCredentials.validate_signature(id_token) do {:ok, _jwk, claims} -> - {:ok, claims} + {:ok, headers} = Joken.peek_header(id_token) + {:ok, client_id_from_headers(headers), claims} {:error, error} -> {:error, @@ -236,52 +419,83 @@ 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 client_id_from_headers(%{"jwk" => jwk}) do + "did:jwk:" <> (Jason.encode!(jwk) |> Base.url_encode64(padding: false)) + end + + defp client_id_from_headers(%{"kid" => kid}), do: kid + + 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 + ) + when not is_nil(vp_token) do + 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} -> {: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( - %{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,15 +504,59 @@ 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 - ) do + ) + when not is_nil(vp_token) do case Jason.decode(presentation_submission) do {:ok, presentation_submission} -> case VerifiablePresentations.validate_presentation( @@ -333,9 +591,10 @@ defmodule Boruta.Openid do end defp maybe_check_presentation( - %{vp_token: _vp_token}, + %{vp_token: vp_token}, _presentation_definition - ) do + ) + when not is_nil(vp_token) do {:error, %Error{ status: :bad_request, @@ -347,6 +606,14 @@ 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: {:ok, code_chain} + + 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..db77be44 100644 --- a/lib/boruta/openid/responses/direct_post.ex +++ b/lib/boruta/openid/responses/direct_post.ex @@ -7,15 +7,25 @@ defmodule Boruta.Openid.DirectPostResponse do :id_token, :vp_token, :code, + :code_chain, :redirect_uri, - :state + :client_encryption_key, + :client_encryption_alg, + :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 + client_encryption_key: map() | nil, + client_encryption_alg: String.t() | nil, + response_types: String.t(), + state: String.t() | nil, + error: Boruta.Oauth.Error.t() | nil } end diff --git a/lib/boruta/openid/responses/siopv2.ex b/lib/boruta/openid/responses/siopv2.ex index 6604adac..79dd6553 100644 --- a/lib/boruta/openid/responses/siopv2.ex +++ b/lib/boruta/openid/responses/siopv2.ex @@ -26,7 +26,9 @@ defmodule Boruta.Openid.SiopV2Response do issuer: nil, client: nil, response_mode: nil, - nonce: nil + nonce: nil, + client_encryption_key: nil, + client_encryption_alg: nil @type t :: %__MODULE__{ client_id: String.t(), @@ -37,10 +39,18 @@ defmodule Boruta.Openid.SiopV2Response do issuer: String.t(), client: Boruta.Oauth.Client.t(), response_mode: String.t(), - nonce: String.t() + nonce: String.t(), + client_encryption_key: map(), + client_encryption_alg: String.t() } - def from_tokens(%{siopv2_code: code, response_mode: response_mode}, request) do + def from_tokens( + %{ + siopv2_code: code, + response_mode: response_mode + }, + request + ) do %__MODULE__{ client_id: request.client_id, code: code, @@ -49,7 +59,9 @@ defmodule Boruta.Openid.SiopV2Response do issuer: Boruta.Config.issuer(), client: code.client, response_mode: response_mode, - nonce: code.nonce + nonce: code.nonce, + client_encryption_key: code.client_encryption_key, + client_encryption_alg: code.client_encryption_alg } end @@ -61,6 +73,7 @@ defmodule Boruta.Openid.SiopV2Response do ) :: deeplink :: String.t() | {:error, reason :: String.t()} def redirect_to_deeplink(%__MODULE__{} = response, redirect_uri_url_fn) do redirect_uri = redirect_uri_url_fn.(response.code.id) + claims = %{ iss: issuer(), aud: response.client_id, @@ -70,25 +83,58 @@ defmodule Boruta.Openid.SiopV2Response do client_id: issuer(), redirect_uri: redirect_uri, scope: "openid", - nonce: response.nonce + nonce: response.nonce, + authorization_server_encryption_key: + JOSE.JWK.from_pem(response.client.public_key) + |> JOSE.JWK.to_map() + |> elem(1), + direct_post_encryption_alg: Client.Crypto.encryption_alg(response.client) } - with "" <> request <- Client.Crypto.id_token_sign(claims, response.client) do - query = - %{ - client_id: response.client_id, - response_type: response.response_type, - response_mode: response.response_mode, - scope: "openid", - redirect_uri: redirect_uri, - request: request - } - |> URI.encode_query() + case {response.client_encryption_key, response.client_encryption_alg} do + {client_encryption_key, client_encryption_alg} + when is_nil(client_encryption_key) or is_nil(client_encryption_alg) -> + with "" <> request <- Client.Crypto.id_token_sign(claims, response.client) do + query = + %{ + client_id: response.client_id, + response_type: response.response_type, + response_mode: response.response_mode, + scope: "openid", + redirect_uri: redirect_uri, + request: request + } + |> URI.encode_query() + + uri = URI.parse(response.redirect_uri) + uri = %{uri | host: uri.host || "", query: query} + + URI.to_string(uri) + end + + {client_encryption_key, client_encryption_alg} -> + with "" <> request <- + Client.Crypto.encrypt( + claims, + client_encryption_key, + client_encryption_alg + ) do + query = + %{ + client_id: response.client_id, + response_type: response.response_type, + response_mode: response.response_mode, + scope: "openid", + redirect_uri: redirect_uri, + request: request + } + |> URI.encode_query() - uri = URI.parse(response.redirect_uri) - uri = %{uri | host: uri.host || "", query: query} + uri = URI.parse(response.redirect_uri) + uri = %{uri | host: uri.host || "", query: query} - URI.to_string(uri) + URI.to_string(uri) + end end end end diff --git a/lib/boruta/openid/responses/verifiable_presentation.ex b/lib/boruta/openid/responses/verifiable_presentation.ex index 6afbbab5..65e047d1 100644 --- a/lib/boruta/openid/responses/verifiable_presentation.ex +++ b/lib/boruta/openid/responses/verifiable_presentation.ex @@ -28,7 +28,9 @@ defmodule Boruta.Openid.VerifiablePresentationResponse do client: nil, response_mode: nil, nonce: nil, - presentation_definition: nil + presentation_definition: nil, + client_encryption_key: nil, + client_encryption_alg: nil @type t :: %__MODULE__{ client_id: String.t(), @@ -40,7 +42,9 @@ defmodule Boruta.Openid.VerifiablePresentationResponse do client: Boruta.Oauth.Client.t(), response_mode: String.t(), nonce: String.t(), - presentation_definition: map() + presentation_definition: map(), + client_encryption_key: map() | nil, + client_encryption_alg: String.t() | nil } def from_tokens(%{vp_code: code, response_mode: response_mode}, request) do @@ -53,7 +57,9 @@ defmodule Boruta.Openid.VerifiablePresentationResponse do client: code.client, response_mode: response_mode, nonce: code.nonce, - presentation_definition: code.presentation_definition + presentation_definition: code.presentation_definition, + client_encryption_key: code.client_encryption_key, + client_encryption_alg: code.client_encryption_alg } end @@ -76,25 +82,58 @@ defmodule Boruta.Openid.VerifiablePresentationResponse do redirect_uri: redirect_uri, scope: "openid", nonce: response.nonce, - presentation_definition: response.presentation_definition + presentation_definition: response.presentation_definition, + authorization_server_encryption_key: + JOSE.JWK.from_pem(response.client.public_key) + |> JOSE.JWK.to_map() + |> elem(1), + direct_post_encryption_alg: Client.Crypto.encryption_alg(response.client) } - with "" <> request <- Client.Crypto.id_token_sign(claims, response.client) do - query = - %{ - client_id: response.client_id, - response_type: response.response_type, - response_mode: response.response_mode, - scope: "openid", - redirect_uri: redirect_uri, - request: request - } - |> URI.encode_query() + case {response.client_encryption_key, response.client_encryption_alg} do + {client_encryption_key, client_encryption_alg} + when is_nil(client_encryption_key) or is_nil(client_encryption_alg) -> + with "" <> request <- Client.Crypto.id_token_sign(claims, response.client) do + query = + %{ + client_id: response.client_id, + response_type: response.response_type, + response_mode: response.response_mode, + scope: "openid", + redirect_uri: redirect_uri, + request: request + } + |> URI.encode_query() + + uri = URI.parse(response.redirect_uri) + uri = %{uri | host: uri.host || "", query: query} + + URI.to_string(uri) + end + + {client_encryption_key, client_encryption_alg} -> + with "" <> request <- + Client.Crypto.encrypt( + claims, + client_encryption_key, + client_encryption_alg + ) do + query = + %{ + client_id: response.client_id, + response_type: response.response_type, + response_mode: response.response_mode, + scope: "openid", + redirect_uri: redirect_uri, + request: request + } + |> URI.encode_query() - uri = URI.parse(response.redirect_uri) - uri = %{uri | host: uri.host || "", query: query} + uri = URI.parse(response.redirect_uri) + uri = %{uri | host: uri.host || "", query: query} - URI.to_string(uri) + URI.to_string(uri) + end end end end diff --git a/lib/boruta/openid/verifiable_credentials.ex b/lib/boruta/openid/verifiable_credentials.ex index 03979a95..8258c2bd 100644 --- a/lib/boruta/openid/verifiable_credentials.ex +++ b/lib/boruta/openid/verifiable_credentials.ex @@ -114,7 +114,7 @@ defmodule Boruta.Openid.VerifiableCredentials do ) end - defp derive_status(status, ttl, secret, [current|status_list]) do + defp derive_status(status, ttl, secret, [current | status_list]) do Hotp.generate_hotp( derive_status(current, ttl, secret, status_list), div(:os.system_time(:seconds), ttl) + shift(status) @@ -210,13 +210,15 @@ defmodule Boruta.Openid.VerifiableCredentials do resource_owner :: ResourceOwner.t(), credential_params :: map(), token :: Boruta.Oauth.Token.t(), - default_credential_configuration :: map() + default_credential_configuration :: map(), + code_chain :: list(Boruta.Oauth.Token.t()) ) :: {:ok, credential :: Credential.t()} | {:error, reason :: String.t()} def issue_verifiable_credential( resource_owner, credential_params, token, - default_credential_configuration + default_credential_configuration, + code_chain \\ [] ) do proof = credential_params["proof"] @@ -226,20 +228,20 @@ defmodule Boruta.Openid.VerifiableCredentials do _ -> resource_owner.credential_configuration end + token_scopes = token_chain_scopes([token | code_chain]) + # TODO filter from resource owner authorization details with {credential_identifier, credential_configuration} <- Enum.find(credential_configuration, fn {identifier, configuration} -> - case configuration[:version] do - "11" -> - (credential_params["types"] && - Enum.empty?(configuration[:types] -- credential_params["types"])) || - Enum.member?(Scope.split(token.scope), identifier) - - "13" -> - (identifier == credential_params["credential_identifier"]) || - Enum.member?(Scope.split(token.scope), identifier) - end + credential_configuration_matches?( + identifier, + configuration, + credential_params, + token_scopes + ) end), + {:ok, credential_configuration} <- + configuration_scope_authorized?(credential_configuration, token_scopes), {:ok, proof} <- validate_proof_format(proof), :ok <- validate_headers(proof["jwt"]), :ok <- validate_claims(proof["jwt"]), @@ -270,6 +272,51 @@ defmodule Boruta.Openid.VerifiableCredentials do end end + defp credential_configuration_matches?( + identifier, + %{version: "11"} = configuration, + credential_params, + token_scopes + ) do + (credential_params["types"] && + Enum.empty?(configuration[:types] -- credential_params["types"])) || + Enum.member?(token_scopes, identifier) + end + + defp credential_configuration_matches?( + identifier, + %{version: "13"}, + credential_params, + token_scopes + ) do + identifier == credential_params["credential_identifier"] || + Enum.member?(token_scopes, identifier) + end + + defp credential_configuration_matches?( + _identifier, + _configuration, + _credential_params, + _token_scopes + ), + do: false + + defp configuration_scope_authorized?(%{scopes: scopes} = configuration, token_scopes) + when is_list(scopes) do + case Enum.all?(scopes, &Enum.member?(token_scopes, &1)) do + true -> {:ok, configuration} + false -> {:error, "Credential scope is not authorized."} + end + end + + defp configuration_scope_authorized?(configuration, _token_scopes), do: {:ok, configuration} + + defp token_chain_scopes(token_chain) do + token_chain + |> Enum.flat_map(fn token -> Scope.split(token.scope) end) + |> Enum.uniq() + end + @spec validate_authorization_details(authorization_details :: String.t()) :: :ok | {:error, reason :: String.t()} def validate_authorization_details(authorization_details) do @@ -427,15 +474,11 @@ defmodule Boruta.Openid.VerifiableCredentials do sub = case Joken.peek_header(proof) do - {:ok, headers} -> - case(extract_key(headers)) do - {_type, key} -> key - end + {:ok, headers} -> proof_subject(headers) end now = :os.system_time(:seconds) credential_id = SecureRandom.uuid() - sub = sub |> String.split("#") |> List.first() payload = %{ "sub" => sub, @@ -495,10 +538,7 @@ defmodule Boruta.Openid.VerifiableCredentials do sub = case Joken.peek_header(proof) do - {:ok, headers} -> - case extract_key(headers) do - {_type, key} -> key - end + {:ok, headers} -> proof_subject(headers) end credential_id = SecureRandom.uuid() @@ -552,10 +592,7 @@ defmodule Boruta.Openid.VerifiableCredentials do sub = case Joken.peek_header(proof) do - {:ok, headers} -> - case(extract_key(headers)) do - {_type, key} -> key - end + {:ok, headers} -> proof_subject(headers) end claims_with_salt = Enum.flat_map(claims, &format_sd_claim(&1, client)) @@ -622,7 +659,9 @@ defmodule Boruta.Openid.VerifiableCredentials do defp format_sd_claim({name, {:items, claims}}, client, path) when is_list(claims) do claims |> Enum.with_index() - |> Enum.flat_map(fn {claim, index} -> format_sd_claim(claim, client, path ++ [name, to_string(index)]) end) + |> Enum.flat_map(fn {claim, index} -> + format_sd_claim(claim, client, path ++ [name, to_string(index)]) + end) end defp format_sd_claim({name, {:claims, claims}}, client, path) when is_list(claims) do @@ -633,13 +672,14 @@ defmodule Boruta.Openid.VerifiableCredentials do name = Enum.join(path ++ [name], ".") # TODO factorize - iss = case client.did do - nil -> - Client.Crypto.kid_from_private_key(client.private_key) + iss = + case client.did do + nil -> + Client.Crypto.kid_from_private_key(client.private_key) - did -> - did <> "#" <> String.replace(did, "did:key:", "") - end + did -> + did <> "#" <> String.replace(did, "did:key:", "") + end [ {name, claim, Status.generate_status_token(iss, ttl, String.to_atom(status))} @@ -694,10 +734,20 @@ defmodule Boruta.Openid.VerifiableCredentials do @individual_claim_default_expiration}} end - defp extract_key(%{"kid" => did}), do: {:did, did} defp extract_key(%{"jwk" => jwk}), do: {:jwk, jwk} + defp extract_key(%{"kid" => did}), do: {:did, did} defp extract_key(_headers), do: {:error, "No proof key material found in JWT headers"} + defp proof_subject(headers) do + case extract_key(headers) do + {:did, did} -> + did |> String.split("#") |> List.first() + + {:jwk, jwk} -> + "did:jwk:" <> (Jason.encode!(jwk) |> Base.url_encode64(padding: false)) + end + end + defp do_validate_headers(checks) do do_validate_headers(checks, []) end diff --git a/lib/boruta/openid/verifiable_presentations.ex b/lib/boruta/openid/verifiable_presentations.ex index ff3081ff..f7f7b1a9 100644 --- a/lib/boruta/openid/verifiable_presentations.ex +++ b/lib/boruta/openid/verifiable_presentations.ex @@ -19,31 +19,44 @@ defmodule Boruta.Openid.VerifiablePresentations do # TODO perform client metadata checks def check_client_metadata(_client_metadata), do: :ok - def response_types("code", _scope, _presentation_configuration), do: ["id_token"] - - def response_types("id_token", _scope, _presentation_configuration), do: ["id_token"] + def response_types(response_type, scope, presentation_configuration) do + response_types = String.split(response_type, " ") + + case response_types do + ["code" | _rest] -> + response_types + + ["id_token" | _rest] -> + response_types + + ["vp_token" | rest] -> + case Enum.any?(Map.keys(presentation_configuration), fn presentation_identifier -> + Enum.member?(Scope.split(scope), presentation_identifier) + end) do + true -> String.split(response_type, " ") + false -> ["id_token" | rest] + end - def response_types("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 -> ["vp_token"] - false -> ["id_token"] + _ -> + [] end end - def presentation_definition(presentation_configuration, scope) do + def presentation_definition(["vp_token" | _response_types], presentation_configuration, scope) do case Enum.find(presentation_configuration, fn {identifier, _configuration} -> Enum.member?(Scope.split(scope), identifier) end) do nil -> - nil + {:ok, nil, nil} - {_identifier, configuration} -> - configuration[:definition] + {identifier, configuration} -> + {:ok, identifier, configuration[:definition]} end end + def presentation_definition(_response_types, _presentation_configuration, _scope), + do: {:ok, nil, nil} + def validate_presentation(vp_token, presentation_submission, presentation_definition) do with :ok <- ExJsonSchema.Validator.validate( @@ -51,6 +64,11 @@ defmodule Boruta.Openid.VerifiablePresentations do presentation_submission, error_formatter: BorutaFormatter ), + :ok <- + validate_descriptor_count( + presentation_definition["input_descriptors"], + presentation_submission["descriptor_map"] + ), {:ok, _jwk, vp_claims} <- validate_signature(vp_token) do Enum.reduce_while( Enum.zip( @@ -76,6 +94,17 @@ defmodule Boruta.Openid.VerifiablePresentations do end end + defp validate_descriptor_count(input_descriptors, descriptor_map) + when is_list(input_descriptors) and is_list(descriptor_map) do + case length(input_descriptors) == length(descriptor_map) do + true -> :ok + false -> {:error, "Input descriptor count does not match descriptor map count."} + end + end + + defp validate_descriptor_count(_input_descriptors, _descriptor_map), + do: {:error, "Input descriptor count does not match descriptor map count."} + defp extract_path(raw_path) do raw_path |> String.split(".") @@ -283,7 +312,7 @@ defmodule Boruta.Openid.VerifiablePresentations do def verify_jwt(error, _alg, _jwt), do: error - defp extract_key(%{"kid" => did}), do: {:did, did} defp extract_key(%{"jwk" => jwk}), do: {:jwk, jwk} + defp extract_key(%{"kid" => did}), do: {:did, did} defp extract_key(_headers), do: {:error, "No proof key material found in JWT headers"} end 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/boruta/migrations/20260210015023_siopv2_encryption.ex b/priv/boruta/migrations/20260210015023_siopv2_encryption.ex new file mode 100644 index 00000000..32ae744d --- /dev/null +++ b/priv/boruta/migrations/20260210015023_siopv2_encryption.ex @@ -0,0 +1,15 @@ +defmodule Boruta.Migrations.Siopv2Encryption do + @moduledoc false + + defmacro __using__(_args) do + quote do + def change do + # 20260210003645_add_client_encryption_to_oauth_tokens.exs + alter table(:oauth_tokens) do + add :client_encryption_key, :jsonb + add :client_encryption_alg, :string + end + end + end + end +end diff --git a/priv/boruta/migrations/20260428120000_requested_scope.ex b/priv/boruta/migrations/20260428120000_requested_scope.ex new file mode 100644 index 00000000..bf001a2e --- /dev/null +++ b/priv/boruta/migrations/20260428120000_requested_scope.ex @@ -0,0 +1,14 @@ +defmodule Boruta.Migrations.RequestedScope do + @moduledoc false + + defmacro __using__(_args) do + quote do + def change do + # 20260428120000_add_requested_scope_to_oauth_tokens.exs + alter table(:oauth_tokens) do + add(:requested_scope, :string, 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/priv/repo/migrations/20260210003645_add_client_encryption_to_oauth_tokens.exs b/priv/repo/migrations/20260210003645_add_client_encryption_to_oauth_tokens.exs new file mode 100644 index 00000000..b92153fe --- /dev/null +++ b/priv/repo/migrations/20260210003645_add_client_encryption_to_oauth_tokens.exs @@ -0,0 +1,10 @@ +defmodule Boruta.Repo.Migrations.AddClientEncryptionToOauthTokens do + use Ecto.Migration + + def change do + alter table(:oauth_tokens) do + add :client_encryption_key, :jsonb + add :client_encryption_alg, :string + end + end +end diff --git a/priv/repo/migrations/20260428120000_add_requested_scope_to_oauth_tokens.exs b/priv/repo/migrations/20260428120000_add_requested_scope_to_oauth_tokens.exs new file mode 100644 index 00000000..1d5451d3 --- /dev/null +++ b/priv/repo/migrations/20260428120000_add_requested_scope_to_oauth_tokens.exs @@ -0,0 +1,9 @@ +defmodule Boruta.Repo.Migrations.AddRequestedScopeToOauthTokens do + use Ecto.Migration + + def change do + alter table(:oauth_tokens) do + add(:requested_scope, :string, 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 690c7e8b..d222f625 100644 --- a/test/boruta/oauth/integration/authorization_code_grant_test.exs +++ b/test/boruta/oauth/integration/authorization_code_grant_test.exs @@ -267,7 +267,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -283,7 +283,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) assert type == :code - assert value + assert code assert expires_in end @@ -296,7 +296,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -312,7 +312,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) assert type == :code - assert value + assert code assert expires_in end @@ -325,7 +325,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -341,7 +341,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) assert type == :code - assert value + assert code assert expires_in end @@ -357,7 +357,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -373,7 +373,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) assert type == :code - assert value + assert code assert expires_in end @@ -419,11 +419,11 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} -> assert type == :code - assert value + assert code assert expires_in _ -> @@ -457,11 +457,11 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} -> assert type == :code - assert value + assert code assert expires_in _ -> @@ -523,11 +523,11 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} -> assert type == :code - assert value + assert code assert expires_in _ -> @@ -561,11 +561,11 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} -> assert type == :code - assert value + assert code assert expires_in _ -> @@ -652,12 +652,12 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in, state: state }} -> assert type == :code - assert value + assert code assert expires_in assert state == given_state @@ -723,7 +723,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in, state: state, code_challenge: code_challenge, @@ -733,14 +733,14 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do code_challenge: repo_code_challenge, code_challenge_method: repo_code_challenge_method, code_challenge_hash: repo_code_challenge_hash - } = Repo.get_by(Ecto.Token, value: value) + } = Repo.get_by(Ecto.Token, value: code.value) assert repo_code_challenge == nil assert repo_code_challenge_method == "S256" assert String.length(repo_code_challenge_hash) == 128 assert type == :code - assert value + assert code assert expires_in assert state == given_state assert code_challenge == given_code_challenge @@ -775,7 +775,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -789,7 +789,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) assert type == :code - assert value + assert code assert expires_in end @@ -855,12 +855,12 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) do {:authorize_success, %AuthorizeResponse{ - code: value + code: code }} -> %Ecto.Token{ code_challenge_method: repo_code_challenge_method, code_challenge_hash: repo_code_challenge_hash - } = Repo.get_by(Ecto.Token, value: value) + } = Repo.get_by(Ecto.Token, value: code.value) assert repo_code_challenge_method == "plain" assert repo_code_challenge_hash == Boruta.Oauth.Token.hash(given_code_challenge) @@ -896,12 +896,12 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) do {:authorize_success, %AuthorizeResponse{ - code: value + code: code }} -> %Ecto.Token{ code_challenge_method: repo_code_challenge_method, code_challenge_hash: repo_code_challenge_hash - } = Repo.get_by(Ecto.Token, value: value) + } = Repo.get_by(Ecto.Token, value: code.value) assert repo_code_challenge_method == "S256" assert repo_code_challenge_hash == Boruta.Oauth.Token.hash(given_code_challenge) @@ -927,7 +927,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -944,13 +944,71 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) assert type == :code - assert value + assert code assert expires_in - assert Repo.get_by(Ecto.Token, value: value).authorization_details == authorization_details + assert Repo.get_by(Ecto.Token, value: code.value).authorization_details == + authorization_details end - test "returns a code with siopv2 (direct_post)" do + test "returns a code with siopv2 (direct_post - jwe)" do + client_private_key = JOSE.JWK.generate_key({:ec, "P-256"}) + client_public_key = JOSE.JWK.to_public(client_private_key) + redirect_uri = "openid:" + + assert {:authorize_success, + %SiopV2Response{ + 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", + "client_encryption_key" => client_public_key |> JOSE.JWK.to_map() |> elem(1), + "client_encryption_alg" => "ECDH-ES" + } + }, + %ResourceOwner{sub: "did:key:test"}, + ApplicationMock + ) + + assert issuer == Boruta.Config.issuer() + assert client.public_client_id == Boruta.Config.issuer() + + assert SiopV2Response.redirect_to_deeplink(response, fn code -> code end) =~ + ~r"#{redirect_uri}" + + [_all, jwe] = + Regex.run( + ~r/request=([^&]+)/, + SiopV2Response.redirect_to_deeplink(response, fn code -> code end) + ) + + assert %{ + "aud" => "did:key:test", + "authorization_server_encryption_key" => %{}, + "client_id" => "boruta", + "iss" => "boruta", + "nonce" => "nonce", + "response_mode" => "direct_post", + "response_type" => "id_token", + "scope" => "openid" + } = JOSE.JWE.block_decrypt(client_private_key, jwe) |> elem(0) |> Jason.decode!() + end + + test "returns a code with siopv2 (direct_post - jwt)" do redirect_uri = "openid:" assert {:authorize_success, @@ -982,6 +1040,68 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert issuer == Boruta.Config.issuer() assert client.public_client_id == Boruta.Config.issuer() + assert SiopV2Response.redirect_to_deeplink(response, fn code -> code end) =~ + ~r"#{redirect_uri}" + + [_all, jwt] = + Regex.run( + ~r/request=([^&]+)/, + SiopV2Response.redirect_to_deeplink(response, fn code -> code end) + ) + + assert {:ok, + %{ + "aud" => "did:key:test", + "authorization_server_encryption_key" => %{}, + "client_id" => "boruta", + "iss" => "boruta", + "nonce" => "nonce", + "response_mode" => "direct_post", + "response_type" => "id_token", + "scope" => "openid" + }} = + Oauth.Client.Crypto.verify_id_token_signature( + jwt, + JOSE.JWK.from_pem(client.private_key) |> JOSE.JWK.to_map() + ) + 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 @@ -1582,7 +1702,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -1620,7 +1740,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do test "returns a token", %{client: client, code: code, resource_owner: resource_owner} do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -1652,7 +1772,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do test "stores previous code", %{client: client, code: code, resource_owner: resource_owner} do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -1682,7 +1802,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -1721,7 +1841,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 3, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -1765,7 +1885,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> + |> expect(:get_by, 1, fn _params -> {:ok, %{ resource_owner @@ -1839,7 +1959,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) Ecto.Codes.get_by(value: code.value, redirect_uri: redirect_uri) @@ -1878,7 +1998,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2033,7 +2153,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do redirect_uri = List.first(client.redirect_uris) ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) case Oauth.token( %Plug.Conn{ @@ -2071,7 +2191,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2110,7 +2230,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2620,7 +2740,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2657,7 +2777,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do test "returns a token", %{client: client, code: code, resource_owner: resource_owner} do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2695,7 +2815,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2730,7 +2850,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do test "stores previous code", %{client: client, code: code, resource_owner: resource_owner} do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2762,7 +2882,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2803,7 +2923,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 3, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -2851,7 +2971,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> + |> expect(:get_by, 1, fn _params -> {:ok, %{ resource_owner @@ -2923,7 +3043,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) Ecto.Codes.get_by(value: code.value, redirect_uri: redirect_uri) @@ -2964,7 +3084,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -3129,7 +3249,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do redirect_uri = List.first(client.redirect_uris) ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) assert {:token_success, %TokenResponse{ @@ -3166,7 +3286,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) @@ -3204,7 +3324,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do resource_owner: resource_owner } do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) redirect_uri = List.first(client.redirect_uris) 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/oauth/integration/hybrid_test.exs b/test/boruta/oauth/integration/hybrid_test.exs index 2400a20e..fe9d818f 100644 --- a/test/boruta/oauth/integration/hybrid_test.exs +++ b/test/boruta/oauth/integration/hybrid_test.exs @@ -568,17 +568,17 @@ defmodule Boruta.OauthTest.HybridGrantTest do assert {:ok, %{resource: ^resource}} = Authorization.Code.authorize(%{ - value: code, + value: code.value, redirect_uri: redirect_uri, client: struct(Client, Map.from_struct(client)), code_verifier: "" }) assert {:ok, %{resource: ^resource}} = - Authorization.AccessToken.authorize(value: access_token, resource: resource) + Authorization.AccessToken.authorize(value: access_token.value, resource: resource) assert {:error, %Error{error: :invalid_access_token}} = - Authorization.AccessToken.authorize(value: access_token, resource: nil) + Authorization.AccessToken.authorize(value: access_token.value, resource: nil) end test "returns a code and a token with `response_mode=query`", %{ @@ -853,7 +853,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -870,7 +870,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do ) assert type == :hybrid - assert value + assert code assert expires_in end @@ -889,7 +889,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -906,7 +906,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do ) assert type == :hybrid - assert value + assert code assert expires_in end @@ -952,7 +952,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -969,7 +969,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do ) assert type == :hybrid - assert value + assert code assert expires_in end @@ -987,7 +987,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in }} = Oauth.authorize( @@ -1004,7 +1004,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do ) assert type == :hybrid - assert value + assert code assert expires_in end @@ -1075,7 +1075,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in, state: state }} = @@ -1093,7 +1093,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do ) assert type == :hybrid - assert value + assert code assert expires_in assert state == given_state end @@ -1141,7 +1141,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - code: value, + code: code, expires_in: expires_in, state: state, code_challenge: code_challenge, @@ -1166,14 +1166,14 @@ defmodule Boruta.OauthTest.HybridGrantTest do code_challenge: repo_code_challenge, code_challenge_method: repo_code_challenge_method, code_challenge_hash: repo_code_challenge_hash - } = Repo.get_by(Ecto.Token, value: value) + } = Repo.get_by(Ecto.Token, value: code.value) assert repo_code_challenge == nil assert repo_code_challenge_method == "S256" assert String.length(repo_code_challenge_hash) == 128 assert type == :hybrid - assert value + assert code assert expires_in assert state == given_state assert code_challenge == given_code_challenge @@ -1190,7 +1190,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do assert {:authorize_success, %AuthorizeResponse{ - code: value + code: code }} = Oauth.authorize( %Plug.Conn{ @@ -1208,7 +1208,7 @@ defmodule Boruta.OauthTest.HybridGrantTest do %Ecto.Token{ code_challenge_method: repo_code_challenge_method - } = Repo.get_by(Ecto.Token, value: value) + } = Repo.get_by(Ecto.Token, value: code.value) assert repo_code_challenge_method == "plain" end diff --git a/test/boruta/oauth/integration/implicit_grant_test.exs b/test/boruta/oauth/integration/implicit_grant_test.exs index d81adef4..6936f655 100644 --- a/test/boruta/oauth/integration/implicit_grant_test.exs +++ b/test/boruta/oauth/integration/implicit_grant_test.exs @@ -50,13 +50,17 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do %ResourceOwner{sub: "sub"}, ApplicationMock ) == - {:authorize_error, - %Error{ - error: :invalid_request, - error_description: - "Query params validation failed. Required properties client_id, redirect_uri are missing at #.", - status: :bad_request - }} + { + :authorize_error, + %Boruta.Oauth.Error{ + error: :invalid_client, + error_description: "Invalid client.", + format: nil, + redirect_uri: nil, + state: nil, + status: :unauthorized + } + } end test "returns an error if client_id is invalid" do @@ -210,7 +214,7 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - access_token: value, + access_token: access_token, expires_in: expires_in, redirect_uri: ^redirect_uri }} = @@ -227,7 +231,7 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do ) assert type == :token - assert value + assert access_token assert expires_in end @@ -240,7 +244,7 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - access_token: value, + access_token: access_token, expires_in: expires_in, redirect_uri: ^redirect_uri }} = @@ -257,7 +261,7 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do ) assert type == :token - assert value + assert access_token assert expires_in end @@ -270,7 +274,7 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - access_token: value, + access_token: access_token, expires_in: expires_in, redirect_uri: ^redirect_uri }} = @@ -287,7 +291,7 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do ) assert type == :token - assert value + assert access_token assert expires_in end @@ -303,7 +307,7 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do assert {:authorize_success, %AuthorizeResponse{ type: type, - access_token: value, + access_token: access_token, expires_in: expires_in, redirect_uri: ^redirect_uri }} = @@ -320,7 +324,7 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do ) assert type == :token - assert value + assert access_token assert expires_in end @@ -576,12 +580,12 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do {:authorize_success, %AuthorizeResponse{ type: type, - access_token: value, + access_token: access_token, expires_in: expires_in, token_type: "bearer" }} -> assert type == :token - assert value + assert access_token assert expires_in _ -> diff --git a/test/boruta/oauth/integration/preauthorize_test.exs b/test/boruta/oauth/integration/preauthorize_test.exs index 0d8d8c8e..1a5cf230 100644 --- a/test/boruta/oauth/integration/preauthorize_test.exs +++ b/test/boruta/oauth/integration/preauthorize_test.exs @@ -53,13 +53,17 @@ defmodule Boruta.OauthTest.PreauthorizeTest do %ResourceOwner{sub: "sub"}, ApplicationMock ) == - {:preauthorize_error, - %Error{ - error: :invalid_request, - error_description: - "Query params validation failed. Required properties client_id, redirect_uri are missing at #.", - status: :bad_request - }} + { + :preauthorize_error, + %Boruta.Oauth.Error{ + error: :invalid_client, + error_description: "Invalid client.", + format: nil, + redirect_uri: nil, + state: nil, + status: :unauthorized + } + } end test "returns an error if client_id is invalid" do diff --git a/test/boruta/oauth/responses/authorize_test.exs b/test/boruta/oauth/responses/authorize_test.exs index fe1218fe..210531d0 100644 --- a/test/boruta/oauth/responses/authorize_test.exs +++ b/test/boruta/oauth/responses/authorize_test.exs @@ -1,6 +1,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do use ExUnit.Case + alias Boruta.Oauth alias Boruta.Oauth.AuthorizeResponse defp assert_url_query(url, expected_query) do @@ -17,7 +18,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns an url with access_token type" do response = %AuthorizeResponse{ type: :token, - access_token: "value", + access_token: %Oauth.Token{type: "access_token", value: "value"}, expires_in: 10, redirect_uri: "http://redirect.uri" } @@ -31,8 +32,8 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns a fragment according to `response_mode` for hybrid requests" do response = %AuthorizeResponse{ type: :hybrid, - code: "value", - access_token: "value", + code: %Oauth.Token{type: "code", value: "value"}, + access_token: %Oauth.Token{type: "access_token", value: "value"}, expires_in: 10, redirect_uri: "http://redirect.uri", response_mode: "fragment" @@ -48,8 +49,8 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns query params according to `response_mode` for hybrid requests" do response = %AuthorizeResponse{ type: :hybrid, - code: "value", - access_token: "value", + code: %Oauth.Token{type: "code", value: "value"}, + access_token: %Oauth.Token{type: "access_token", value: "value"}, expires_in: 10, redirect_uri: "http://redirect.uri", response_mode: "query" @@ -65,7 +66,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns an url with access_token type and a state" do response = %AuthorizeResponse{ type: :token, - access_token: "value", + access_token: %Oauth.Token{type: "access_token", value: "value"}, expires_in: 10, state: "state", redirect_uri: "http://redirect.uri" @@ -81,7 +82,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns an url with hybrid type" do response = %AuthorizeResponse{ type: :hybrid, - access_token: "access_token", + access_token: %Oauth.Token{type: "access_token", value: "access_token"}, id_token: "id_token", expires_in: 10, redirect_uri: "http://redirect.uri" @@ -97,7 +98,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns an url with hybrid type, a state and a token_type" do response = %AuthorizeResponse{ type: :hybrid, - access_token: "access_token", + access_token: %Oauth.Token{type: "access_token", value: "access_token"}, id_token: "id_token", expires_in: 10, state: "state", @@ -117,7 +118,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns an url with code type" do response = %AuthorizeResponse{ type: :code, - code: "value", + code: %Oauth.Token{type: "code", value: "value"}, redirect_uri: "http://redirect.uri" } @@ -127,7 +128,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns an url with code type and a state" do response = %AuthorizeResponse{ type: :code, - code: "value", + code: %Oauth.Token{type: "code", value: "value"}, state: "state", redirect_uri: "http://redirect.uri" } @@ -141,7 +142,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns an url with a query in redirect_uri" do response = %AuthorizeResponse{ type: :code, - code: "value", + code: %Oauth.Token{type: "code", value: "value"}, state: "state", redirect_uri: "http://redirect.uri?foo=bar" } @@ -156,7 +157,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns query params according to `response_mode` for code requests" do response = %AuthorizeResponse{ type: :code, - code: "value", + code: %Oauth.Token{type: "code", value: "value"}, state: "state", redirect_uri: "http://redirect.uri", response_mode: "query" @@ -171,7 +172,7 @@ defmodule Boruta.Oauth.AuthorizeResponseTest do test "returns fragment according to `response_mode` for token requests" do response = %AuthorizeResponse{ type: :token, - access_token: "value", + access_token: %Oauth.Token{type: "access_token", value: "value"}, expires_in: 10, redirect_uri: "http://redirect.uri", response_mode: "fragment" 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..9e1b9ae2 100644 --- a/test/boruta/openid/integration/direct_post_test.exs +++ b/test/boruta/openid/integration/direct_post_test.exs @@ -1,10 +1,12 @@ 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.Oauth.Client.Crypto alias Boruta.Openid alias Boruta.Openid.ApplicationMock alias Boruta.Openid.VerifiablePresentations @@ -12,12 +14,14 @@ 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() - wallet_did = - "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ" + {: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_fixture() pkce_client = insert(:client, pkce: true, redirect_uris: ["https://redirect.uri"]) @@ -56,6 +60,76 @@ 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", @@ -91,15 +165,14 @@ defmodule Boruta.OpenidTest.DirectPostTest do signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ - "kid" => wallet_did, + "jwk" => public_jwk_fixture(), "typ" => "openid4vci-proof+jwt" }) {:ok, id_token, _claims} = VerifiablePresentations.Token.generate_and_sign( %{ - "iss" => - "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ" + "iss" => wallet_did }, signer ) @@ -119,8 +192,7 @@ defmodule Boruta.OpenidTest.DirectPostTest do {:ok, vp_token, _claims} = VerifiablePresentations.Token.generate_and_sign( %{ - "iss" => - "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ", + "iss" => wallet_did, "vp" => %{ "verifiableCredential" => [credential] } @@ -134,6 +206,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 +322,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,7 +377,8 @@ defmodule Boruta.OpenidTest.DirectPostTest do ) end - test "siopv2 - returns an error with bad public client", %{ + @tag :skip + test "siopv2 - authenticates with bad public client", %{ id_token: id_token, bad_public_client_code: code } do @@ -342,8 +388,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: "Authorization client_id do not match vp_token signature.", format: :query, redirect_uri: "http://redirect.uri", state: "state" @@ -377,6 +422,32 @@ defmodule Boruta.OpenidTest.DirectPostTest do assert response.state == code.state end + test "siopv2 - authenticates (jwe)", %{id_token: id_token, code: code} do + conn = %Plug.Conn{} + + response = + Crypto.encrypt( + %{id_token: id_token}, + JOSE.JWK.from_pem(code.client.public_key) |> JOSE.JWK.to_map(), + "ECDH-ES" + ) + + assert {:direct_post_success, response} = + Openid.direct_post( + conn, + %{ + code_id: code.id, + response: response + }, + ApplicationMock + ) + + assert response.id_token + assert response.redirect_uri == code.redirect_uri + assert response.code.value == code.value + assert response.state == code.state + end + test "siopv2 - authenticates with public client", %{ id_token: id_token, public_client_code: code @@ -527,6 +598,7 @@ defmodule Boruta.OpenidTest.DirectPostTest do ) end + @tag :skip test "oid4vp - returns an error on replay", %{vp_token: vp_token, code: code} do conn = %Plug.Conn{} @@ -672,6 +744,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 +773,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" @@ -755,6 +827,53 @@ defmodule Boruta.OpenidTest.DirectPostTest do assert response.state == code.state end + test "oid4vp - authenticates (jwe)", %{vp_token: vp_token, code: code} 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]" + } + } + ] + }) + + response = + Crypto.encrypt( + %{ + vp_token: vp_token, + presentation_submission: presentation_submission + }, + JOSE.JWK.from_pem(code.client.public_key) |> JOSE.JWK.to_map(), + "ECDH-ES" + ) + + assert {:direct_post_success, response} = + Openid.direct_post( + conn, + %{ + code_id: code.id, + response: response + }, + ApplicationMock + ) + + assert response.vp_token + assert response.redirect_uri == code.redirect_uri + assert response.code.value == code.value + assert response.state == code.state + end + test "oid4vp - authenticates with a public client", %{ vp_token: vp_token, public_client_code: code @@ -796,6 +915,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 @@ -843,6 +1152,17 @@ defmodule Boruta.OpenidTest.DirectPostTest do "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" end + def public_jwk_fixture do + public_key_fixture() + |> JOSE.JWK.from_pem() + |> JOSE.JWK.to_map() + |> elem(1) + end + + def did_jwk_fixture do + "did:jwk:" <> (Jason.encode!(public_jwk_fixture()) |> Base.url_encode64(padding: false)) + end + def private_key_fixture do "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVO\nf8cU8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa\n9QyHsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8Wd\nSq3dGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/\nU8xDZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2t\npyQ0AEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQABAoIBAG0dg/upL8k1IWiv\n8BNphrXIYLYQmiiBQTPJWZGvWIC2sl7i40yvCXjDjiRnZNK9HwgL94XtALCXYRFR\nJD41bRA3MO5A0HSPIWwJXwS10/cU56HVCNHjwKa6Rz/QiG2kNASMZEMzlvHtrjna\ndx36/sjI3HH8gh1BaTZyiuDE72SMkPbL838jfL1YY9uJ0u6hWFDbdn3sqPfJ6Cnz\n1cu0piT35nkilnIGCNYA0i3lyMeo4XrdXaAJdN9nnqbCi5ewQWqaHbrIIY5LTgzJ\nYlOr3IiecyokFxHCbULXle60u0KqXYgBHmlQJJr1Dj4c9AkQmefjC2jRMlhOrIzo\nIkIUeMECgYEA+MNLB+w6vv1ogqzM3M1OLt6bziWJCn+XkziuMrCiY9KeDD+S70+E\nhfbhM5RjCE3wxC/k59039laT973BmdMHxrDd2zSjOFmCIORv5yrD5oBHMaMZcwuQ\n45Xisi4aoQoOhyznSnjo/RjeQB7qEDzXFznLLNT79HzqyAtCWD3UIu8CgYEA2yik\n9FKl7HJEY94D2K6vNh1AHGnkwIQC72pXzlUrVuwQYngj6/Gkhw8ayFBApHfwVCXj\no9rDYPdNrrAs0Zz0JsiJp6bOCEKCrMYE16UiejUUAg/OZ5eg6+3m3/iWatkzLUuK\n1LIkVBJlEyY0uPuAaBF0V0VleNvfCGhVYOn46+ECgYAUD4OsduNh5YOZDiBTKgdF\nBlSgMiyz+QgbKjX6Bn6B+EkgibvqqonwV7FffHbkA40H9SjLfe52YhL6poXHRtpY\nroillcAX2jgBOQrBJJS5sNyM5y81NNiRUdP/NHKXS/1R71ATlF6NkoTRvOx5NL7P\ns6xryB0tYSl5ylamUQ4bZwKBgHF6FB9mA//wErVbKcayfIqajq2nrwh30kVBXQG7\nW9uAE+PIrWDoF/bOvWFnHHGMoOYRUFNxXKUCqDiBhFNs34aNY6lpV1kzhxIK3ksC\neF2qyhdfM9Kz0mEXJ+pkfw4INNWJPfNv4hueArPtnnMB1rUMBJ+DkU0JG+zwiPTL\ncVZBAoGBAM6kOsh5KGn3aI83g9ZO0TrKLXXFotxJt31Wu11ydj9K33/Qj3UXcxd4\nJPXr600F0DkLeUKBob6BALeHFWcrSz5FGLGRqdRxdv+L6g18WH5m2xEs7o6M6e5I\nIhyUC60ZewJ2M8rV4KgCJJdZE2kENlSgjU92IDVPT9Oetrc7hQJd\n-----END RSA PRIVATE KEY-----\n\n" end diff --git a/test/boruta/openid/integration/jwks_test.exs b/test/boruta/openid/integration/jwks_test.exs index 97babba6..237f6b22 100644 --- a/test/boruta/openid/integration/jwks_test.exs +++ b/test/boruta/openid/integration/jwks_test.exs @@ -19,6 +19,7 @@ defmodule Boruta.OpenidTest.JwksTest do assert {:jwk_list, jwk_keys} = Openid.jwks(%Plug.Conn{}, ApplicationMock) assert Enum.member?(jwk_keys, %{ + "alg" => "RS512", "kid" => "Ac9ufCpgwReXGJ6LI", "e" => "AQAB", "kty" => "RSA", diff --git a/test/boruta/openid/integration/preauthorized_code_grant_test.exs b/test/boruta/openid/integration/preauthorized_code_grant_test.exs index a83adb9d..9436b8f6 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 @@ -141,7 +142,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do # ) # end - test "returns an error if scope is unknown or unauthorized", %{ + test "filters unknown or unauthorized scopes", %{ client_with_scope: client, resource_owner: resource_owner } do @@ -151,26 +152,23 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do given_scope = "bad_scope" redirect_uri = List.first(client.redirect_uris) - assert 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, - "scope" => given_scope - } - }, - resource_owner, - ApplicationMock - ) == - {:authorize_error, - %Error{ - error: :invalid_scope, - error_description: "Given scopes are unknown or unauthorized.", - format: :fragment, - redirect_uri: "https://redirect.uri", - status: :bad_request - }} + assert {:authorize_success, + %CredentialOfferResponse{ + code: %Oauth.Token{scope: ""}, + redirect_uri: ^redirect_uri + }} = + 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, + "scope" => given_scope + } + }, + resource_owner, + ApplicationMock + ) end test "returns an error when agent_token is invalid", %{ @@ -192,7 +190,7 @@ 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 }} = @@ -232,12 +230,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 @@ -280,7 +357,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do assert preauthorized_code end - test "returns a credential offer response bound to resource", %{ + test "returns a credential offer response with a code bound to resource", %{ resource_owner: resource_owner } do resource = "https://mcp.example.com" @@ -292,6 +369,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do ) redirect_uri = List.first(client.redirect_uris) + code = insert(:token, type: "code") resource_owner = %{ resource_owner @@ -304,6 +382,10 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do 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 @@ -316,6 +398,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do "response_type" => "urn:ietf:params:oauth:response-type:pre-authorized_code", "client_id" => client.id, "redirect_uri" => redirect_uri, + "code" => code.value, "resource" => resource } }, @@ -323,7 +406,14 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do ApplicationMock ) - assert %Ecto.Token{resource: ^resource} = + assert preauthorized_code + + previous_code = code.value + + assert {:ok, %Oauth.Token{previous_code: ^previous_code}} = + Ecto.TokenStore.get(value: preauthorized_code) + + assert %Ecto.Token{previous_code: ^previous_code, resource: ^resource} = Repo.get_by(Boruta.Ecto.Token, value: preauthorized_code) end @@ -1008,7 +1098,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do test "returns a token", %{code: code, resource_owner: resource_owner} do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) assert {:token_success, %TokenResponse{ @@ -1061,7 +1151,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do ) ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) assert {:token_success, %TokenResponse{ @@ -1112,7 +1202,7 @@ defmodule Boruta.OauthTest.PreauthorizedCodeGrantTest do test "returns a token with tx code", %{tx_code_code: code, resource_owner: resource_owner} do ResourceOwners - |> expect(:get_by, 2, fn _params -> {:ok, resource_owner} end) + |> expect(:get_by, 1, fn _params -> {:ok, resource_owner} end) assert {:token_success, %TokenResponse{ diff --git a/test/boruta/openid/verifiable_credentials_test.exs b/test/boruta/openid/verifiable_credentials_test.exs index da4e6a9b..2f1638e1 100644 --- a/test/boruta/openid/verifiable_credentials_test.exs +++ b/test/boruta/openid/verifiable_credentials_test.exs @@ -12,8 +12,7 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do setup do signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ - "kid" => - "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ", + "jwk" => public_jwk_fixture(), "typ" => "openid4vci-proof+jwt" }) @@ -108,7 +107,10 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do credential_params: credential_params } do signer = - Joken.Signer.create("HS256", "secret", %{"kid" => "kid", "typ" => "openid4vci-proof+jwt"}) + Joken.Signer.create("HS256", "secret", %{ + "jwk" => public_jwk_fixture(), + "typ" => "openid4vci-proof+jwt" + }) {:ok, token, _claims} = VerifiableCredentials.Token.generate_and_sign(%{}, signer) @@ -132,7 +134,7 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ "typ" => "unknown", - "kid" => "kid" + "jwk" => public_jwk_fixture() }) {:ok, token, _claims} = VerifiableCredentials.Token.generate_and_sign(%{}, signer) @@ -174,6 +176,29 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do ) == {:error, "No proof key material found in JWT headers."} end + test "prefers jwk header over kid when validating proof signature" do + signer = + Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ + "jwk" => public_jwk_fixture(), + "kid" => "did:example:unknown", + "typ" => "openid4vci-proof+jwt" + }) + + {:ok, token, _claims} = + VerifiableCredentials.Token.generate_and_sign( + %{ + "aud" => Config.issuer(), + "iat" => :os.system_time(:seconds) + }, + signer + ) + + assert {:ok, jwk, %{"aud" => _aud, "iat" => _iat}} = + VerifiableCredentials.validate_signature(token) + + assert jwk == public_jwk_fixture() + end + test "verifies proof - must have required claims", %{ resource_owner: resource_owner, credential_params: credential_params @@ -181,7 +206,7 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ "typ" => "openid4vci-proof+jwt", - "kid" => "kid" + "jwk" => public_jwk_fixture() }) {:ok, token, _claims} = VerifiableCredentials.Token.generate_and_sign(%{}, signer) @@ -221,6 +246,152 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do assert credential end + test "issues credential selected by configuration scopes", %{ + resource_owner: resource_owner, + credential_params: credential_params + } do + jwk = + private_key_fixture() + |> JOSE.JWK.from_pem() + |> JOSE.JWK.to_public() + |> JOSE.JWK.to_map() + |> elem(1) + + signer = + Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ + "jwk" => jwk, + "typ" => "openid4vci-proof+jwt" + }) + + {:ok, jwt, _claims} = + VerifiableCredentials.Token.generate_and_sign( + %{ + "aud" => Config.issuer(), + "iat" => :os.system_time(:seconds) + }, + signer + ) + + resource_owner = %ResourceOwner{ + resource_owner + | credential_configuration: %{ + "VerifiableCredential" => + Map.put( + resource_owner.credential_configuration["VerifiableCredential"], + :scopes, + ["credential:read"] + ) + } + } + + credential_params = + credential_params + |> Map.put("proof", %{"proof_type" => "jwt", "jwt" => jwt}) + + token = insert(:token, scope: "credential:read") |> to_oauth_schema() + + assert {:ok, + %{ + credential: credential, + format: "jwt_vc" + }} = + VerifiableCredentials.issue_verifiable_credential( + resource_owner, + credential_params, + token, + %{} + ) + + assert credential + end + + test "issues credential selected by configuration scopes from code chain", %{ + resource_owner: resource_owner, + credential_params: credential_params + } do + jwk = + private_key_fixture() + |> JOSE.JWK.from_pem() + |> JOSE.JWK.to_public() + |> JOSE.JWK.to_map() + |> elem(1) + + signer = + Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ + "jwk" => jwk, + "typ" => "openid4vci-proof+jwt" + }) + + {:ok, jwt, _claims} = + VerifiableCredentials.Token.generate_and_sign( + %{ + "aud" => Config.issuer(), + "iat" => :os.system_time(:seconds) + }, + signer + ) + + resource_owner = %ResourceOwner{ + resource_owner + | credential_configuration: %{ + "VerifiableCredential" => + Map.put( + resource_owner.credential_configuration["VerifiableCredential"], + :scopes, + ["credential:read"] + ) + } + } + + credential_params = + credential_params + |> Map.put("proof", %{"proof_type" => "jwt", "jwt" => jwt}) + + token = insert(:token, scope: "other:scope") |> to_oauth_schema() + code_chain = [insert(:token, type: "code", scope: "credential:read") |> to_oauth_schema()] + + assert {:ok, + %{ + credential: credential, + format: "jwt_vc" + }} = + VerifiableCredentials.issue_verifiable_credential( + resource_owner, + credential_params, + token, + %{}, + code_chain + ) + + assert credential + end + + test "returns an error when configuration scope is not authorized", %{ + resource_owner: resource_owner, + credential_params: credential_params + } do + resource_owner = %ResourceOwner{ + resource_owner + | credential_configuration: %{ + "VerifiableCredential" => + Map.put( + resource_owner.credential_configuration["VerifiableCredential"], + :scopes, + ["other:scope"] + ) + } + } + + token = insert(:token, scope: "credential:read") |> to_oauth_schema() + + assert VerifiableCredentials.issue_verifiable_credential( + resource_owner, + credential_params, + token, + %{} + ) == {:error, "Credential scope is not authorized."} + end + test "issues jwt_vc credential with nested claims", %{ credential_params: credential_params } do @@ -549,7 +720,8 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do [_salt3, "nested.firstname", "firstname"], [_salt4, "nested.twice.lastname", "lastname"] ] = - Enum.map(claims, &Base.url_decode64!(&1, padding: false)) |> Enum.map(&Jason.decode!/1) + Enum.map(claims, &Base.url_decode64!(&1, padding: false)) + |> Enum.map(&Jason.decode!/1) end test "issues vc+sd-jwt credential - valid", %{ @@ -591,6 +763,7 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do } token = insert(:token) |> to_oauth_schema() + assert {:ok, %{ credential: credential, @@ -634,7 +807,9 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do } } } + token = insert(:token) |> to_oauth_schema() + assert {:ok, %{ credential: credential, @@ -678,7 +853,9 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do } } } + token = insert(:token) |> to_oauth_schema() + assert {:ok, %{ credential: credential, @@ -722,7 +899,9 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do } } } + token = insert(:token) |> to_oauth_schema() + assert {:ok, %{ credential: credential, @@ -773,21 +952,27 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do secret = "secret" expiration = 10 now = :os.system_time(:seconds) - revoked = VerifiableCredentials.Hotp.generate_hotp( - secret, - div(now, expiration) + - VerifiableCredentials.Status.shift(:revoked) - ) - suspended = VerifiableCredentials.Hotp.generate_hotp( - revoked, - div(now, expiration) + - VerifiableCredentials.Status.shift(:suspended) - ) - valid = VerifiableCredentials.Hotp.generate_hotp( - suspended, - div(now, expiration) + - VerifiableCredentials.Status.shift(:valid) - ) + + revoked = + VerifiableCredentials.Hotp.generate_hotp( + secret, + div(now, expiration) + + VerifiableCredentials.Status.shift(:revoked) + ) + + suspended = + VerifiableCredentials.Hotp.generate_hotp( + revoked, + div(now, expiration) + + VerifiableCredentials.Status.shift(:suspended) + ) + + valid = + VerifiableCredentials.Hotp.generate_hotp( + suspended, + div(now, expiration) + + VerifiableCredentials.Status.shift(:valid) + ) {:ok, expiration: expiration, secret: secret, status_list: valid, now: now} end @@ -884,6 +1069,13 @@ defmodule Boruta.Openid.VerifiableCredentialsTest do "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" end + def public_jwk_fixture do + public_key_fixture() + |> JOSE.JWK.from_pem() + |> JOSE.JWK.to_map() + |> elem(1) + end + def private_key_fixture do "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVO\nf8cU8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa\n9QyHsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8Wd\nSq3dGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/\nU8xDZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2t\npyQ0AEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQABAoIBAG0dg/upL8k1IWiv\n8BNphrXIYLYQmiiBQTPJWZGvWIC2sl7i40yvCXjDjiRnZNK9HwgL94XtALCXYRFR\nJD41bRA3MO5A0HSPIWwJXwS10/cU56HVCNHjwKa6Rz/QiG2kNASMZEMzlvHtrjna\ndx36/sjI3HH8gh1BaTZyiuDE72SMkPbL838jfL1YY9uJ0u6hWFDbdn3sqPfJ6Cnz\n1cu0piT35nkilnIGCNYA0i3lyMeo4XrdXaAJdN9nnqbCi5ewQWqaHbrIIY5LTgzJ\nYlOr3IiecyokFxHCbULXle60u0KqXYgBHmlQJJr1Dj4c9AkQmefjC2jRMlhOrIzo\nIkIUeMECgYEA+MNLB+w6vv1ogqzM3M1OLt6bziWJCn+XkziuMrCiY9KeDD+S70+E\nhfbhM5RjCE3wxC/k59039laT973BmdMHxrDd2zSjOFmCIORv5yrD5oBHMaMZcwuQ\n45Xisi4aoQoOhyznSnjo/RjeQB7qEDzXFznLLNT79HzqyAtCWD3UIu8CgYEA2yik\n9FKl7HJEY94D2K6vNh1AHGnkwIQC72pXzlUrVuwQYngj6/Gkhw8ayFBApHfwVCXj\no9rDYPdNrrAs0Zz0JsiJp6bOCEKCrMYE16UiejUUAg/OZ5eg6+3m3/iWatkzLUuK\n1LIkVBJlEyY0uPuAaBF0V0VleNvfCGhVYOn46+ECgYAUD4OsduNh5YOZDiBTKgdF\nBlSgMiyz+QgbKjX6Bn6B+EkgibvqqonwV7FffHbkA40H9SjLfe52YhL6poXHRtpY\nroillcAX2jgBOQrBJJS5sNyM5y81NNiRUdP/NHKXS/1R71ATlF6NkoTRvOx5NL7P\ns6xryB0tYSl5ylamUQ4bZwKBgHF6FB9mA//wErVbKcayfIqajq2nrwh30kVBXQG7\nW9uAE+PIrWDoF/bOvWFnHHGMoOYRUFNxXKUCqDiBhFNs34aNY6lpV1kzhxIK3ksC\neF2qyhdfM9Kz0mEXJ+pkfw4INNWJPfNv4hueArPtnnMB1rUMBJ+DkU0JG+zwiPTL\ncVZBAoGBAM6kOsh5KGn3aI83g9ZO0TrKLXXFotxJt31Wu11ydj9K33/Qj3UXcxd4\nJPXr600F0DkLeUKBob6BALeHFWcrSz5FGLGRqdRxdv+L6g18WH5m2xEs7o6M6e5I\nIhyUC60ZewJ2M8rV4KgCJJdZE2kENlSgjU92IDVPT9Oetrc7hQJd\n-----END RSA PRIVATE KEY-----\n\n" end diff --git a/test/boruta/openid/verifiable_presentations_test.exs b/test/boruta/openid/verifiable_presentations_test.exs index 41e63d6b..4ba3dc6f 100644 --- a/test/boruta/openid/verifiable_presentations_test.exs +++ b/test/boruta/openid/verifiable_presentations_test.exs @@ -6,8 +6,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do setup do signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ - "kid" => - "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ", + "jwk" => public_jwk_fixture(), "typ" => "openid4vci-proof+jwt" }) @@ -22,8 +21,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do {:ok, expired_vp_token, _claims} = VerifiablePresentations.Token.generate_and_sign( %{ - "iss" => - "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ", + "iss" => did_jwk_fixture(), "vp" => %{ "verifiableCredential" => [expired_credential] } @@ -47,8 +45,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do {:ok, vp_token, _claims} = VerifiablePresentations.Token.generate_and_sign( %{ - "iss" => - "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ", + "iss" => did_jwk_fixture(), "vp" => %{ "verifiableCredential" => [credential] } @@ -191,22 +188,100 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do presentation_definition ) == :ok end + + test "returns an error when input descriptor and descriptor map counts do not match", %{ + vp_token: vp_token + } do + presentation_submission = %{ + "id" => "test", + "definition_id" => "test", + "descriptor_map" => [ + %{ + "id" => "test", + "format" => "jwt_vp", + "path" => "$", + "path_nested" => %{ + "id" => "test", + "format" => "jwt_vc", + "path" => "$.vp.verifiableCredential[0]" + } + } + ] + } + + presentation_definition = %{ + "id" => "test", + "format" => %{"jwt_vc" => %{"alg" => ["ES256'"]}, "jwt_vp" => %{"alg" => ["ES256"]}}, + "input_descriptors" => [ + %{ + "id" => "test", + "format" => %{"jwt_vc" => %{"alg" => ["ES256"]}}, + "constraints" => %{ + "fields" => [] + } + }, + %{ + "id" => "test-2", + "format" => %{"jwt_vc" => %{"alg" => ["ES256"]}}, + "constraints" => %{ + "fields" => [] + } + } + ] + } + + assert VerifiablePresentations.validate_presentation( + vp_token, + presentation_submission, + presentation_definition + ) == {:error, "Input descriptor count does not match descriptor map count."} + end end describe "validate_credential/3" do setup do signer = Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ - "kid" => - "did:jwk:eyJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiIxUGFQX2diWGl4NWl0alJDYWVndklfQjNhRk9lb3hsd1BQTHZmTEhHQTRRZkRtVk9mOGNVOE91WkZBWXpMQXJXM1BubndXV3kzOW5WSk94NDJRUlZHQ0dkVUNtVjdzaERIUnNyODYtMkRsTDdwd1VhOVF5SHNUajg0ZkFKbjJGdjloOW1xckl2VXpBdEVZUmxHRnZqVlRHQ3d6RXVsbHBzQjBHSmFmb3BVVEZieThXZFNxM2RHTEpCQjFyLVE4UXRabkF4eHZvbGh3T21Za0Jra2lkZWZtbTQ4WDdoRlhMMmNTSm0yRzd3UXlpbk9leV9VOHhEWjY4bWdUYWtpcVMyUnRqbkZEMGRucEJsNUNZVGU0czZvWktFeUZpRk5pVzRLa1IxR1Zqc0t3WTlvQzJ0cHlRMEFFVU12azlUOVZkSWx0U0lpQXZPS2x3RnpMNDljZ3daRHcifQ", + "jwk" => public_jwk_fixture(), "typ" => "openid4vci-proof+jwt" }) + {:ok, signer: signer} end test "returns an error with unknown format" do assert VerifiablePresentations.validate_credential("", %{}, "unknown") == - {:error, "format \"unknown\" is not supported"} + {:error, "format \"unknown\" is not supported"} + end + + test "prefers jwk header over kid when validating signature" do + signer = + Joken.Signer.create("RS256", %{"pem" => private_key_fixture()}, %{ + "jwk" => public_jwk_fixture(), + "kid" => "did:example:unknown", + "typ" => "openid4vci-proof+jwt" + }) + + {:ok, credential, _claims} = + VerifiablePresentations.Token.generate_and_sign( + %{ + "exp" => :os.system_time(:second) + 10, + "vc" => %{ + "validFrom" => DateTime.utc_now() |> DateTime.add(-10) |> DateTime.to_iso8601(), + "type" => ["VerifiableAttestation"] + } + }, + signer + ) + + descriptor = %{ + "id" => "test", + "constraints" => %{ + "fields" => [] + } + } + + assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == :ok end test "returns an error when descriptor is invalid", %{signer: signer} do @@ -226,7 +301,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do descriptor = %{} assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - {:error, "descriptor is invalid."} + {:error, "descriptor is invalid."} end test "returns an error when credential expired", %{signer: signer} do @@ -252,7 +327,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do } assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - {:error, "is expired."} + {:error, "is expired."} end test "returns an error when not yet valid", %{signer: signer} do @@ -293,7 +368,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do } assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - {:error, "is not yet valid."} + {:error, "is not yet valid."} end @tag :skip @@ -307,7 +382,8 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do "type" => ["VerifiableAttestation"], "test" => "pattern", "credentialStatus" => %{ - "statusListCredential" => "https://api-conformance.ebsi.eu/trusted-issuers-registry/v5/issuers/did:ebsi:zjHZjJ4Sy7r92BxXzFGs7qD/proxies/0x0090e5904a806f9228f88a502e4788d512288c9ba22106f16b5ae7b279ae3598/credentials/status/1", + "statusListCredential" => + "https://api-conformance.ebsi.eu/trusted-issuers-registry/v5/issuers/did:ebsi:zjHZjJ4Sy7r92BxXzFGs7qD/proxies/0x0090e5904a806f9228f88a502e4788d512288c9ba22106f16b5ae7b279ae3598/credentials/status/1", "statusListIndex" => "7" } } @@ -339,7 +415,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do } assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - {:error, "is revoked."} + {:error, "is revoked."} end test "validates contains constraint", %{signer: signer} do @@ -373,7 +449,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do } assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - {:error, "descriptor test does not contains \"not present\"."} + {:error, "descriptor test does not contains \"not present\"."} end test "validates pattern", %{signer: signer} do @@ -406,7 +482,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do } assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - {:error, "descriptor test does not contain pattern \"non-existing\"."} + {:error, "descriptor test does not contain pattern \"non-existing\"."} end test "is valid", %{signer: signer} do @@ -447,7 +523,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do } assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - :ok + :ok end end @@ -455,6 +531,17 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" end + def public_jwk_fixture do + public_key_fixture() + |> JOSE.JWK.from_pem() + |> JOSE.JWK.to_map() + |> elem(1) + end + + def did_jwk_fixture do + "did:jwk:" <> (Jason.encode!(public_jwk_fixture()) |> Base.url_encode64(padding: false)) + end + def private_key_fixture do "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVO\nf8cU8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa\n9QyHsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8Wd\nSq3dGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/\nU8xDZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2t\npyQ0AEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQABAoIBAG0dg/upL8k1IWiv\n8BNphrXIYLYQmiiBQTPJWZGvWIC2sl7i40yvCXjDjiRnZNK9HwgL94XtALCXYRFR\nJD41bRA3MO5A0HSPIWwJXwS10/cU56HVCNHjwKa6Rz/QiG2kNASMZEMzlvHtrjna\ndx36/sjI3HH8gh1BaTZyiuDE72SMkPbL838jfL1YY9uJ0u6hWFDbdn3sqPfJ6Cnz\n1cu0piT35nkilnIGCNYA0i3lyMeo4XrdXaAJdN9nnqbCi5ewQWqaHbrIIY5LTgzJ\nYlOr3IiecyokFxHCbULXle60u0KqXYgBHmlQJJr1Dj4c9AkQmefjC2jRMlhOrIzo\nIkIUeMECgYEA+MNLB+w6vv1ogqzM3M1OLt6bziWJCn+XkziuMrCiY9KeDD+S70+E\nhfbhM5RjCE3wxC/k59039laT973BmdMHxrDd2zSjOFmCIORv5yrD5oBHMaMZcwuQ\n45Xisi4aoQoOhyznSnjo/RjeQB7qEDzXFznLLNT79HzqyAtCWD3UIu8CgYEA2yik\n9FKl7HJEY94D2K6vNh1AHGnkwIQC72pXzlUrVuwQYngj6/Gkhw8ayFBApHfwVCXj\no9rDYPdNrrAs0Zz0JsiJp6bOCEKCrMYE16UiejUUAg/OZ5eg6+3m3/iWatkzLUuK\n1LIkVBJlEyY0uPuAaBF0V0VleNvfCGhVYOn46+ECgYAUD4OsduNh5YOZDiBTKgdF\nBlSgMiyz+QgbKjX6Bn6B+EkgibvqqonwV7FffHbkA40H9SjLfe52YhL6poXHRtpY\nroillcAX2jgBOQrBJJS5sNyM5y81NNiRUdP/NHKXS/1R71ATlF6NkoTRvOx5NL7P\ns6xryB0tYSl5ylamUQ4bZwKBgHF6FB9mA//wErVbKcayfIqajq2nrwh30kVBXQG7\nW9uAE+PIrWDoF/bOvWFnHHGMoOYRUFNxXKUCqDiBhFNs34aNY6lpV1kzhxIK3ksC\neF2qyhdfM9Kz0mEXJ+pkfw4INNWJPfNv4hueArPtnnMB1rUMBJ+DkU0JG+zwiPTL\ncVZBAoGBAM6kOsh5KGn3aI83g9ZO0TrKLXXFotxJt31Wu11ydj9K33/Qj3UXcxd4\nJPXr600F0DkLeUKBob6BALeHFWcrSz5FGLGRqdRxdv+L6g18WH5m2xEs7o6M6e5I\nIhyUC60ZewJ2M8rV4KgCJJdZE2kENlSgjU92IDVPT9Oetrc7hQJd\n-----END RSA PRIVATE KEY-----\n\n" end