diff --git a/CHANGELOG.md b/CHANGELOG.md index 71864f02..99e085cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) according to OAuth / OpenID connect specifications, changes may break in order to comply with those. +## [unreleased] + +### Added + +- support OID4VCI tx codes +- return oauth token in credential issuance response +- expose previous code in oauth tokens +- support sd-jwt in presentations +- accept `JWT` proof `typ` +- signatures adapters with the implementation of Universal adapter +- better code errors on direct post requests +- verifiable credentials nested claims management +- agent credentials and agent code flows + + +### Changed + +- verifiable credentials payloads (sd-jwt, jwt_vc) +- status tokens refactoring and improvement + +### Fixed + +- verifiable presentations various improvement and fixes +- oauth client long dids persistence +- do not use es256 to verify eddsa jwts + ## [3.0.0-beta.3] - 2024-11-21 ### Changed diff --git a/lib/boruta/adapters/ecto/codes.ex b/lib/boruta/adapters/ecto/codes.ex index 06c281e4..7bc47777 100644 --- a/lib/boruta/adapters/ecto/codes.ex +++ b/lib/boruta/adapters/ecto/codes.ex @@ -111,7 +111,9 @@ defmodule Boruta.Ecto.Codes do code_challenge_method: code_challenge_method, authorization_details: authorization_details, presentation_definition: params[:presentation_definition], - public_client_id: params[:public_client_id] + public_client_id: params[:public_client_id], + relying_party_redirect_uri: params[:relying_party_redirect_uri], + previous_code: params[:previous_code] } ]) diff --git a/lib/boruta/adapters/ecto/schemas/token.ex b/lib/boruta/adapters/ecto/schemas/token.ex index 4ab5f64f..8cf0b94f 100644 --- a/lib/boruta/adapters/ecto/schemas/token.ex +++ b/lib/boruta/adapters/ecto/schemas/token.ex @@ -25,6 +25,7 @@ defmodule Boruta.Ecto.Token do c_nonce: String.t(), scope: String.t(), redirect_uri: String.t(), + relying_party_redirect_uri: String.t() | nil, expires_at: integer(), client: Client.t(), public_client_id: String.t(), @@ -70,6 +71,7 @@ defmodule Boruta.Ecto.Token do field(:c_nonce, :string) field(:scope, :string, default: "") field(:redirect_uri, :string) + field(:relying_party_redirect_uri, :string) field(:expires_at, :integer) field(:revoked_at, :utc_datetime_usec) field(:refresh_token_revoked_at, :utc_datetime_usec) @@ -99,6 +101,7 @@ defmodule Boruta.Ecto.Token do |> cast(attrs, [ :client_id, :redirect_uri, + :relying_party_redirect_uri, :sub, :state, :nonce, @@ -260,7 +263,9 @@ defmodule Boruta.Ecto.Token do :nonce, :scope, :authorization_details, - :presentation_definition + :presentation_definition, + :relying_party_redirect_uri, + :previous_code ]) |> validate_required([:authorization_code_ttl, :client_id, :sub, :redirect_uri]) |> foreign_key_constraint(:client_id) @@ -284,7 +289,9 @@ defmodule Boruta.Ecto.Token do :code_challenge, :code_challenge_method, :authorization_details, - :presentation_definition + :presentation_definition, + :relying_party_redirect_uri, + :previous_code ]) |> validate_required([ :authorization_code_ttl, diff --git a/lib/boruta/oauth/authorization.ex b/lib/boruta/oauth/authorization.ex index 46d5f96a..51ea2c5c 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -35,6 +35,7 @@ defmodule Boruta.Oauth.AuthorizationSuccess do public_client_id: nil, client: nil, redirect_uri: nil, + relying_party_redirect_uri: nil, resource_owner: nil, sub: nil, scope: nil, @@ -59,6 +60,7 @@ defmodule Boruta.Oauth.AuthorizationSuccess do access_token: Boruta.Oauth.Token.t() | nil, code: Boruta.Oauth.Token.t() | nil, redirect_uri: String.t() | nil, + relying_party_redirect_uri: String.t() | nil, sub: String.t() | nil, resource_owner: Boruta.Oauth.ResourceOwner.t() | nil, scope: String.t(), @@ -270,15 +272,13 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AuthorizationCodeRequest d redirect_uri: redirect_uri, client: client, code_verifier: code_verifier - }), - {:ok, %ResourceOwner{sub: sub}} <- - Authorization.ResourceOwner.authorize(resource_owner: code.resource_owner) do + }) do {:ok, %AuthorizationSuccess{ client: client, code: code, redirect_uri: redirect_uri, - sub: sub, + sub: code.resource_owner.sub, scope: code.scope, nonce: code.nonce, authorization_details: code.authorization_details @@ -952,23 +952,26 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do alias Boruta.Oauth.CodeRequest alias Boruta.Oauth.Error alias Boruta.Oauth.PresentationRequest + alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.Token alias Boruta.Openid.VerifiableCredentials alias Boruta.Openid.VerifiablePresentations 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, + relying_party_redirect_uri: relying_party_redirect_uri, + resource_owner: resource_owner, + response_type: response_type, + scope: scope, + state: state } = request ) do with [response_type] = response_types <- @@ -977,10 +980,11 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do scope, resource_owner.presentation_configuration ), + # TODO perform public client redirect_uri check {:ok, client} <- (case client_id do "did:" <> _key -> - {:ok, ClientsAdapter.public!()} + {:ok, ClientsAdapter.public!()} _ -> Authorization.Client.authorize( @@ -990,10 +994,18 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do grant_type: response_type ) end), - {:ok, resource_owner} <- - (case client_id do - "did:" <> _key -> {:ok, resource_owner} - _ -> Authorization.ResourceOwner.authorize(resource_owner: resource_owner) + {:ok, client} <- + (case relying_party_redirect_uri do + nil -> + {:ok, client} + + relying_party_redirect_uri -> + Authorization.Client.authorize( + id: client_id, + source: nil, + redirect_uri: relying_party_redirect_uri, + grant_type: response_type + ) end), :ok <- Authorization.Nonce.authorize(request), :ok <- VerifiableCredentials.validate_authorization_details(authorization_details), @@ -1011,19 +1023,21 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do {:ok, %AuthorizationSuccess{ - response_types: response_types, + authorization_details: Jason.decode!(authorization_details), + client: client, + code: code, + code_challenge: code_challenge, + code_challenge_method: code_challenge_method, + nonce: nonce, presentation_definition: presentation_definition, - redirect_uri: redirect_uri, public_client_id: client_id, - client: client, - sub: resource_owner.sub, + redirect_uri: redirect_uri, + relying_party_redirect_uri: relying_party_redirect_uri, + response_mode: client.response_mode, + response_types: response_types, scope: scope, state: state, - nonce: nonce, - code_challenge: code_challenge, - code_challenge_method: code_challenge_method, - authorization_details: Jason.decode!(authorization_details), - response_mode: client.response_mode + sub: client_id }} else {:error, :invalid_code_challenge} -> @@ -1042,26 +1056,32 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do def token(request) do with {:ok, %AuthorizationSuccess{ - response_types: response_types, + authorization_details: authorization_details, + client: client, + code: code, + code_challenge: code_challenge, + code_challenge_method: code_challenge_method, + nonce: nonce, presentation_definition: presentation_definition, - redirect_uri: redirect_uri, public_client_id: public_client_id, - client: client, - sub: sub, + redirect_uri: redirect_uri, + relying_party_redirect_uri: relying_party_redirect_uri, + response_mode: response_mode, + response_types: response_types, scope: scope, state: state, - nonce: nonce, - code_challenge: code_challenge, - code_challenge_method: code_challenge_method, - authorization_details: authorization_details, - response_mode: response_mode + sub: sub }} <- preauthorize(request) do + # TODO create a presentation specific code with {:ok, code} <- CodesAdapter.create(%{ + resource_owner: %ResourceOwner{sub: sub}, client: client, public_client_id: public_client_id, redirect_uri: redirect_uri, + relying_party_redirect_uri: relying_party_redirect_uri, + previous_code: code, sub: sub, scope: scope, state: state, diff --git a/lib/boruta/oauth/authorization/client.ex b/lib/boruta/oauth/authorization/client.ex index dc395cba..9b679b76 100644 --- a/lib/boruta/oauth/authorization/client.ex +++ b/lib/boruta/oauth/authorization/client.ex @@ -49,10 +49,11 @@ defmodule Boruta.Oauth.Authorization.Client do def authorize( id: "did:" <> _key, source: _source, - redirect_uri: _redirect_uri, + redirect_uri: redirect_uri, grant_type: grant_type ) do with %Client{} = client <- ClientsAdapter.public!(), + true <- Enum.member?(client.redirect_uris, redirect_uri) || {:error, "Invalid redirect_uri."}, true <- Client.wallet_grant_type_supported?(client, grant_type) do {:ok, client} else diff --git a/lib/boruta/oauth/authorization/resource_owner.ex b/lib/boruta/oauth/authorization/resource_owner.ex index 1855733e..80658319 100644 --- a/lib/boruta/oauth/authorization/resource_owner.ex +++ b/lib/boruta/oauth/authorization/resource_owner.ex @@ -41,9 +41,19 @@ defmodule Boruta.Oauth.Authorization.ResourceOwner do }} end end + + def authorize(resource_owner: %ResourceOwner{sub: "did:" <> _key}) do + {:error, %Error{ + status: :unauthorized, + error: :invalid_resource_owner, + error_description: "Resource owner is invalid." + }} + end + def authorize(resource_owner: %ResourceOwner{sub: sub} = resource_owner) when not is_nil(sub) do {:ok, resource_owner} end + def authorize(_) do {:error, %Error{ status: :unauthorized, diff --git a/lib/boruta/oauth/contexts/resource_owners.ex b/lib/boruta/oauth/contexts/resource_owners.ex index 9580b701..41fb5053 100644 --- a/lib/boruta/oauth/contexts/resource_owners.ex +++ b/lib/boruta/oauth/contexts/resource_owners.ex @@ -31,5 +31,10 @@ defmodule Boruta.Oauth.ResourceOwners do @callback claims(resource_owner :: ResourceOwner.t(), scope :: String.t()) :: claims :: Boruta.Oauth.IdToken.claims() + # TODO documentation + @callback from_holder(holder :: map()) :: + {:ok, resource_owner :: ResourceOwner.t()} + | {:error, reason :: String.t()} + @optional_callbacks claims: 2 end diff --git a/lib/boruta/oauth/error.ex b/lib/boruta/oauth/error.ex index f0096311..c39f625e 100644 --- a/lib/boruta/oauth/error.ex +++ b/lib/boruta/oauth/error.ex @@ -28,7 +28,8 @@ defmodule Boruta.Oauth.Error do error_description: String.t(), format: :query | :fragment | :json | nil, redirect_uri: String.t() | nil, - state: String.t() | nil + state: String.t() | nil, + code: String.t() | nil } @enforce_keys [:status, :error, :error_description] @@ -37,7 +38,8 @@ defmodule Boruta.Oauth.Error do error_description: nil, format: nil, redirect_uri: nil, - state: nil + state: nil, + code: nil @doc """ Returns the OAuth error augmented with the format according to request type. diff --git a/lib/boruta/oauth/request/base.ex b/lib/boruta/oauth/request/base.ex index 67a4f650..30a82210 100644 --- a/lib/boruta/oauth/request/base.ex +++ b/lib/boruta/oauth/request/base.ex @@ -130,11 +130,13 @@ defmodule Boruta.Oauth.Request.Base do client_id: params["client_id"], resource_owner: params["resource_owner"], redirect_uri: params["redirect_uri"], + relying_party_redirect_uri: params["relying_party_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"], scope: params["scope"], client_metadata: client_metadata, response_type: params["response_type"] diff --git a/lib/boruta/oauth/requests/presentation_request.ex b/lib/boruta/oauth/requests/presentation_request.ex index 513ce7e4..373af0bc 100644 --- a/lib/boruta/oauth/requests/presentation_request.ex +++ b/lib/boruta/oauth/requests/presentation_request.ex @@ -8,8 +8,10 @@ defmodule Boruta.Oauth.PresentationRequest do """ @type t :: %__MODULE__{ client_id: String.t(), + code: String.t() | nil, resource_owner: Boruta.Oauth.ResourceOwner.t(), redirect_uri: String.t(), + relying_party_redirect_uri: String.t(), state: String.t(), nonce: String.t(), prompt: String.t(), @@ -24,8 +26,10 @@ defmodule Boruta.Oauth.PresentationRequest do @enforce_keys [:client_id, :redirect_uri] defstruct client_id: nil, + code: nil, resource_owner: nil, redirect_uri: nil, + relying_party_redirect_uri: nil, state: "", nonce: "", prompt: "", diff --git a/lib/boruta/oauth/schemas/token.ex b/lib/boruta/oauth/schemas/token.ex index dad0c4f8..ee407af2 100644 --- a/lib/boruta/oauth/schemas/token.ex +++ b/lib/boruta/oauth/schemas/token.ex @@ -23,6 +23,7 @@ defmodule Boruta.Oauth.Token do c_nonce: nil, scope: nil, redirect_uri: nil, + relying_party_redirect_uri: nil, expires_at: nil, client: nil, public_client_id: nil, @@ -53,6 +54,7 @@ defmodule Boruta.Oauth.Token do c_nonce: String.t() | nil, scope: String.t(), redirect_uri: String.t() | nil, + relying_party_redirect_uri: String.t() | nil, expires_at: integer() | nil, client: Boruta.Oauth.Client.t() | nil, public_client_id: String.t() | nil, diff --git a/lib/boruta/openid.ex b/lib/boruta/openid.ex index a35b115d..f5ed8f73 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -24,6 +24,9 @@ defmodule Boruta.Openid do > The definition of those callbacks are provided by either `Boruta.Openid.Application` or `Boruta.Openid.JwksApplication` and `Boruta.Openid.UserinfoApplication` """ + import Boruta.Config, only: [resource_owners: 0] + + alias Boruta.AccessTokensAdapter alias Boruta.ClientsAdapter alias Boruta.CodesAdapter alias Boruta.CredentialsAdapter @@ -160,15 +163,72 @@ defmodule Boruta.Openid do }), :ok <- maybe_check_public_client_id(direct_post_params, code.public_client_id, code.client), - :ok <- maybe_check_presentation(direct_post_params, code.presentation_definition), + {:ok, sub, presentation_claims} <- + maybe_check_presentation(direct_post_params, code.presentation_definition), {:ok, _code} <- CodesAdapter.revoke(code) do - module.direct_post_success(conn, %DirectPostResponse{ - id_token: direct_post_params[:id_token], - vp_token: direct_post_params[:vp_token], - code: code, - redirect_uri: code.redirect_uri, - state: code.state - }) + case direct_post_params[:vp_token] do + nil -> + module.direct_post_success(conn, %DirectPostResponse{ + id_token: direct_post_params[:id_token], + vp_token: direct_post_params[:vp_token], + code: code, + redirect_uri: code.redirect_uri, + state: code.state + }) + + _vp_token -> + with {:ok, resource_owner} <- + resource_owners().from_holder(%{ + presentation_claims: presentation_claims, + sub: sub, + scope: code.scope + }), + {:ok, scope} <- + Authorization.Scope.authorize( + scope: code.scope, + against: %{client: code.client, resource_owner: resource_owner} + ), + {:ok, token} <- + AccessTokensAdapter.create( + %{ + client: code.client, + resource_owner: resource_owner, + redirect_uri: code.relying_party_redirect_uri, + sub: resource_owner.sub, + scope: scope, + state: code.state, + previous_code: code.value + }, + refresh_token: false + ) do + module.direct_post_success(conn, %DirectPostResponse{ + id_token: direct_post_params[:id_token], + vp_token: direct_post_params[:vp_token], + token: token, + code: code, + redirect_uri: code.redirect_uri, + state: code.state + }) + else + {:error, "" <> error} -> + module.authentication_failure(conn, %Error{ + error: :unknown_error, + status: :unprocessable_entity, + error_description: error, + format: :query, + redirect_uri: code.redirect_uri, + state: code.state + }) + + {:error, error} -> + module.authentication_failure(conn, %{ + error + | format: :query, + redirect_uri: code.redirect_uri, + state: code.state + }) + end + end else {:error, "" <> error} -> module.authentication_failure(conn, %Error{ @@ -177,7 +237,8 @@ defmodule Boruta.Openid do error_description: error, format: :query, redirect_uri: code.redirect_uri, - state: code.state + state: code.state, + code: code.value }) {:error, error} -> @@ -185,7 +246,8 @@ defmodule Boruta.Openid do error | format: :query, redirect_uri: code.redirect_uri, - state: code.state + state: code.state, + code: code.value }) end else @@ -198,7 +260,7 @@ defmodule Boruta.Openid do end defp check_id_token_client(%{id_token: id_token}) do - case VerifiableCredentials.validate_signature(id_token) do + case VerifiablePresentations.validate_signature(id_token) do {:ok, _jwk, claims} -> {:ok, claims} @@ -242,13 +304,13 @@ defmodule Boruta.Openid do do: :ok defp maybe_check_public_client_id( - %{id_token: id_token}, + %{vp_token: vp_token}, "did:" <> _key = public_client_id, _client ) do - with {:ok, %{"alg" => alg}} <- Joken.peek_header(id_token), + with {:ok, %{"alg" => alg}} <- Joken.peek_header(vp_token), {:ok, _jwk, _claims} <- - VerifiablePresentations.verify_jwt({:did, public_client_id}, alg, id_token) do + VerifiablePresentations.verify_jwt({:did, public_client_id}, alg, vp_token) do :ok else {:error, _error} -> @@ -262,23 +324,11 @@ defmodule Boruta.Openid do end defp maybe_check_public_client_id( - %{vp_token: vp_token}, - "did:" <> _key = public_client_id, + %{id_token: _id_token}, + "did:" <> _key, _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 - else - {:error, _error} -> - {:error, - %Error{ - status: :bad_request, - error: :invalid_client, - error_description: "Authorization client_id do not match vp_token signature." - }} - end + :ok end defp maybe_check_public_client_id(_direct_post_params, public_client_id, _client) do @@ -290,6 +340,7 @@ defmodule Boruta.Openid do error: :invalid_client, error_description: "Authorization client_id do not match vp_token signature." }} + _client_id -> :ok end @@ -306,8 +357,8 @@ defmodule Boruta.Openid do presentation_submission, presentation_definition ) do - :ok -> - :ok + {:ok, sub, claims} -> + {:ok, sub, claims} {:error, error} -> error = %Error{ @@ -345,7 +396,7 @@ defmodule Boruta.Openid do }} end - defp maybe_check_presentation(_, _), do: :ok + defp maybe_check_presentation(_, _), do: {:ok, nil, %{}} alias Boruta.Openid.Json.Schema alias ExJsonSchema.Validator.Error.BorutaFormatter diff --git a/lib/boruta/openid/application.ex b/lib/boruta/openid/application.ex index d49a0127..d32716e8 100644 --- a/lib/boruta/openid/application.ex +++ b/lib/boruta/openid/application.ex @@ -39,7 +39,10 @@ defmodule Boruta.Openid.Application do @doc """ This function will be triggered in case of success invoking `Boruta.Openid.credential/3` """ - @callback credential_created(conn :: Plug.Conn.t(), credential :: Boruta.Openid.CredentialResponse.t()) :: + @callback credential_created( + conn :: Plug.Conn.t(), + credential :: Boruta.Openid.CredentialResponse.t() + ) :: any() @doc """ This function will be triggered in case of failure invoking `Boruta.Openid.credential/3` @@ -48,7 +51,7 @@ defmodule Boruta.Openid.Application do any() @callback direct_post_success( conn :: Plug.Conn.t() | map(), - response :: any() + response :: Boruta.Openid.DirectPostResponse.t() ) :: any() @callback code_not_found(conn :: Plug.Conn.t()) :: any() @callback authentication_failure(conn :: Plug.Conn.t(), error :: Boruta.Oauth.Error.t()) :: diff --git a/lib/boruta/openid/applications/direct_post_application.ex b/lib/boruta/openid/applications/direct_post_application.ex index 48187c82..1342e0ef 100644 --- a/lib/boruta/openid/applications/direct_post_application.ex +++ b/lib/boruta/openid/applications/direct_post_application.ex @@ -6,7 +6,7 @@ defmodule Boruta.Openid.DirectPostApplication do @callback direct_post_success( conn :: Plug.Conn.t() | map(), - response :: any() + response :: Boruta.Openid.DirectPostResponse.t() ) :: any() @callback code_not_found(conn :: Plug.Conn.t()) :: any() @callback authentication_failure(conn :: Plug.Conn.t(), error :: Boruta.Oauth.Error.t()) :: diff --git a/lib/boruta/openid/json/schema.ex b/lib/boruta/openid/json/schema.ex index 22ace64b..6bfc2ff0 100644 --- a/lib/boruta/openid/json/schema.ex +++ b/lib/boruta/openid/json/schema.ex @@ -39,19 +39,19 @@ defmodule Boruta.Openid.Json.Schema do "type" => "object", "properties" => %{ "id" => %{"type" => "string"}, - "format" => %{"type" => "string", "pattern" => "^jwt_vp$"}, + "format" => %{"type" => "string", "pattern" => "^jwt_vp|vc.sd.jwt$"}, "path" => %{"type" => "string"}, "path_nested" => %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string"}, - "format" => %{"type" => "string", "pattern" => "^jwt_vc$"}, + "format" => %{"type" => "string", "pattern" => "^jwt_vc|vc.sd.jwt$"}, "path" => %{"type" => "string"} }, "required" => ["id", "format", "path"] } }, - "required" => ["id", "format", "path", "path_nested"] + "required" => ["id", "format", "path"] } } }, diff --git a/lib/boruta/openid/responses/direct_post.ex b/lib/boruta/openid/responses/direct_post.ex index 9db111b4..631285cc 100644 --- a/lib/boruta/openid/responses/direct_post.ex +++ b/lib/boruta/openid/responses/direct_post.ex @@ -6,6 +6,7 @@ defmodule Boruta.Openid.DirectPostResponse do defstruct [ :id_token, :vp_token, + :token, :code, :redirect_uri, :state @@ -15,6 +16,7 @@ defmodule Boruta.Openid.DirectPostResponse do id_token: String.t() | nil, vp_token: String.t() | nil, code: Boruta.Oauth.Token.t(), + token: Boruta.Oauth.Token.t() | nil, redirect_uri: String.t(), state: String.t() | nil } diff --git a/lib/boruta/openid/verifiable_credentials.ex b/lib/boruta/openid/verifiable_credentials.ex index 03979a95..9c6f21b4 100644 --- a/lib/boruta/openid/verifiable_credentials.ex +++ b/lib/boruta/openid/verifiable_credentials.ex @@ -514,6 +514,7 @@ defmodule Boruta.Openid.VerifiableCredentials do "issuanceDate" => DateTime.from_unix!(now) |> DateTime.to_iso8601(), "type" => credential_configuration[:types], "issuer" => Did.controller(client.did) || Config.issuer(), + "sub" => sub, "validFrom" => DateTime.utc_now() |> DateTime.to_iso8601(), "nbf" => now, "iat" => now, diff --git a/lib/boruta/openid/verifiable_presentations.ex b/lib/boruta/openid/verifiable_presentations.ex index ff3081ff..93f33a49 100644 --- a/lib/boruta/openid/verifiable_presentations.ex +++ b/lib/boruta/openid/verifiable_presentations.ex @@ -52,21 +52,24 @@ defmodule Boruta.Openid.VerifiablePresentations do error_formatter: BorutaFormatter ), {:ok, _jwk, vp_claims} <- validate_signature(vp_token) do - Enum.reduce_while( + case Enum.reduce_while( Enum.zip( presentation_definition["input_descriptors"], presentation_submission["descriptor_map"] ), - {:error, "No credentials presented."}, - fn {descriptor, map}, _acc -> + {:ok, nil, %{}}, + fn {descriptor, map}, {:ok, _sub, claims} -> credential = get_in(vp_claims, extract_path(map["path_nested"]["path"])) case validate_credential(credential, descriptor, extract_format(map)) do - :ok -> {:cont, :ok} + {:ok, sub, current_claims} -> {:cont, {:ok, sub, Map.merge(claims, current_claims)}} {:error, error} -> {:halt, {:error, map["id"] <> " " <> error}} end end - ) + ) do + {:ok, nil, _claims} -> {:error, "No credentials presented."} + result -> result + end else {:error, errors} when is_list(errors) -> {:error, Enum.join(errors, ", ")} @@ -95,13 +98,43 @@ defmodule Boruta.Openid.VerifiablePresentations do end defp extract_format(%{"path_nested" => %{"format" => format}}), do: format + defp extract_format(%{"format" => format}), do: format + + def validate_credential(credential, descriptor, "vc+sd-jwt") do + [credential|disclosures] = String.split(credential, "~") + with {:ok, _jwk, %{"_sd" => sd, "sub" => sub}} <- validate_signature(credential) do + claims = Enum.map(disclosures, fn disclosure -> + with {:ok, json} <- Base.url_decode64(disclosure, padding: false), + {:ok, [_salt, key, value]} <- Jason.decode(json), + true <- Enum.any?(sd, fn sd -> + case Base.url_decode64(sd, padding: false) do + {:ok, sd} -> + :crypto.hash(:sha256, disclosure) == sd + _ -> false + end + end) do + {key, value} + else + _ -> + nil + end + end) + |> Enum.reject(&is_nil/1) + |> Enum.into(%{}) + + with {:ok, filtered_claims} <- validate_constraints(claims, descriptor) do + {:ok, sub, filtered_claims} + end + end + end def validate_credential(credential, descriptor, "jwt_vc") do with {:ok, _jwk, claims} <- validate_signature(credential), :ok <- validate_expiration(claims), :ok <- validate_valid_from(claims), - :ok <- validate_status_list(claims) do - validate_constraints(claims, descriptor) + :ok <- validate_status_list(claims), + {:ok, filtered_claims} <- validate_constraints(claims, descriptor) do + {:ok, claims["sub"], filtered_claims} end end @@ -177,15 +210,17 @@ defmodule Boruta.Openid.VerifiablePresentations do "constraints" => %{"fields" => fields_constraints} }) do Enum.reduce_while(fields_constraints, :ok, fn constraint, _result -> - case Enum.reduce_while(constraint["path"], :ok, fn path, _result -> - value = get_in(claims, extract_path(path)) + case Enum.reduce_while(constraint["path"], {:ok, %{}}, fn path, + {:ok, presentation_claims} -> + path = extract_path(path) + value = get_in(claims, path) case validate_filter(value, constraint["filter"]) do - :ok -> {:cont, :ok} + :ok -> {:cont, {:ok, Map.put(presentation_claims, Enum.join(path, "."), value)}} error -> {:halt, error} end end) do - :ok -> {:cont, :ok} + {:ok, claims} -> {:cont, {:ok, claims}} {:error, error} -> {:halt, {:error, "descriptor #{id} #{error}"}} end end) @@ -224,6 +259,7 @@ defmodule Boruta.Openid.VerifiablePresentations do @spec validate_signature(jwt :: String.t()) :: {:ok, jwk :: map(), claims :: map()} | {:error, reason :: String.t()} def validate_signature(jwt) when is_binary(jwt) do + jwt = String.split(jwt, "~") |> List.first() case Joken.peek_header(jwt) do {:ok, %{"alg" => alg} = headers} -> verify_jwt(extract_key(headers), alg, jwt) diff --git a/priv/boruta/migrations/20240919135309_relying_party_redirect_uri.ex b/priv/boruta/migrations/20240919135309_relying_party_redirect_uri.ex new file mode 100644 index 00000000..50723a64 --- /dev/null +++ b/priv/boruta/migrations/20240919135309_relying_party_redirect_uri.ex @@ -0,0 +1,14 @@ +defmodule Boruta.Migrations.RelyingPartyRedirectUri do + @moduledoc false + + defmacro __using__(_args) do + quote do + def change do + # 20240919113816_add_relying_party_redirect_uri_to_oauth_tokens.exs + alter table(:oauth_tokens) do + add :relying_party_redirect_uri, :string + end + end + end + end +end diff --git a/priv/repo/migrations/20240919113816_add_relying_party_redirect_uri_to_oauth_tokens.exs b/priv/repo/migrations/20240919113816_add_relying_party_redirect_uri_to_oauth_tokens.exs new file mode 100644 index 00000000..bad55464 --- /dev/null +++ b/priv/repo/migrations/20240919113816_add_relying_party_redirect_uri_to_oauth_tokens.exs @@ -0,0 +1,9 @@ +defmodule Boruta.Repo.Migrations.AddRelyingPartyRedirectUriToOauthTokens do + use Ecto.Migration + + def change do + alter table(:oauth_tokens) do + add :relying_party_redirect_uri, :string + end + end +end diff --git a/test/boruta/oauth/integration/authorization_code_grant_test.exs b/test/boruta/oauth/integration/authorization_code_grant_test.exs index ab94d820..6affee4c 100644 --- a/test/boruta/oauth/integration/authorization_code_grant_test.exs +++ b/test/boruta/oauth/integration/authorization_code_grant_test.exs @@ -241,12 +241,15 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ) end + @tag :skip + test "anonymous client tests" + test "returns an error with anonymous clients (wallets)", %{resource_owner: resource_owner} do assert {:authorize_error, %Error{ status: :bad_request, - error: :unsupported_grant_type, - error_description: "Client do not support given grant type." + error: :invalid_request, + error_description: "Invalid redirect_uri." }} = Oauth.authorize( %Plug.Conn{ @@ -918,6 +921,36 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert Repo.get_by(Ecto.Token, value: value).authorization_details == authorization_details end + test "returns an error with siopv2 when relying party redirect uri do not match (direct_post)" do + redirect_uri = "openid:" + + assert { + :authorize_error, + %Error{ + status: :bad_request, + error: :invalid_request, + error_description: "Invalid redirect_uri.", + format: :query, + redirect_uri: "openid:" + } + } = + Oauth.authorize( + %Plug.Conn{ + query_params: %{ + "response_type" => "code", + "client_id" => "did:key:test", + "redirect_uri" => redirect_uri, + "relying_party_redirect_uri" => "http://bad.uri", + "client_metadata" => "{}", + "nonce" => "nonce", + "scope" => "openid" + } + }, + %ResourceOwner{sub: "did:key:test"}, + ApplicationMock + ) + end + test "returns a code with siopv2 (direct_post)" do redirect_uri = "openid:" @@ -954,9 +987,50 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ~r"#{redirect_uri}" end + test "returns a code with siopv2 - previous_code (direct_post)" do + redirect_uri = "openid:" + code = "code" + + assert {:authorize_success, + %SiopV2Response{ + code: response_code, + client: client, + client_id: "did:key:test", + response_type: "id_token", + redirect_uri: ^redirect_uri, + scope: "openid", + issuer: issuer, + response_mode: "direct_post", + nonce: "nonce" + } = response} = + Oauth.authorize( + %Plug.Conn{ + query_params: %{ + "response_type" => "code", + "client_id" => "did:key:test", + "redirect_uri" => redirect_uri, + "client_metadata" => "{}", + "nonce" => "nonce", + "scope" => "openid", + "code" => code + } + }, + %ResourceOwner{sub: "did:key:test"}, + ApplicationMock + ) + + assert issuer == Boruta.Config.issuer() + assert client.public_client_id == Boruta.Config.issuer() + assert response_code.previous_code == code + + assert SiopV2Response.redirect_to_deeplink(response, fn code -> code end) =~ + ~r"#{redirect_uri}" + end + test "returns a code with siopv2 (post)" do redirect_uri = "openid://" - client = insert(:client, response_mode: "post", redirect_uris: [redirect_uri]) + relying_party_redirect_uri = "https://redirect.uri" + client = insert(:client, response_mode: "post", redirect_uris: [redirect_uri, relying_party_redirect_uri]) assert {:authorize_success, %SiopV2Response{ @@ -975,12 +1049,13 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do "response_type" => "code", "client_id" => client.id, "redirect_uri" => redirect_uri, + "relying_party_redirect_uri" => relying_party_redirect_uri, "client_metadata" => "{}", "nonce" => "nonce", "scope" => "openid" } }, - %ResourceOwner{sub: "did:key:test"}, + %ResourceOwner{sub: "sub"}, ApplicationMock ) @@ -989,6 +1064,49 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do assert client_id == client.id end + test "returns an error with verifiable presentation when relying party redirect uri is invalid (direct_post)" do + redirect_uri = "openid:" + insert(:scope, name: "vp_token", public: true) + + resource_owner = %ResourceOwner{ + sub: "did:key:test", + presentation_configuration: %{ + "vp_token" => %{ + definition: %{"test" => true} + } + } + } + + assert { + :authorize_error, + %Error{ + status: :bad_request, + error: :invalid_request, + error_description: "Invalid redirect_uri.", + format: :query, + redirect_uri: "openid:" + } + } = + Oauth.authorize( + %Plug.Conn{ + query_params: %{ + "response_type" => "vp_token", + "client_id" => "did:key:test", + "relying_party_redirect_uri" => "http://bad.uri", + "redirect_uri" => redirect_uri, + "client_metadata" => "{}", + "nonce" => "nonce", + "scope" => "openid vp_token" + } + }, + resource_owner, + ApplicationMock + ) + end + + @tag :skip + test "returns a code with verifiable presentation and valid relying party redirect uri (direct_post)" + test "returns a code with verifiable presentation (direct_post)" do redirect_uri = "openid:" insert(:scope, name: "vp_token", public: true) @@ -1042,7 +1160,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do insert(:scope, name: "vp_token", public: true) resource_owner = %ResourceOwner{ - sub: "did:key:test", + sub: "sub", presentation_configuration: %{ "vp_token" => %{ definition: %{"test" => true} diff --git a/test/boruta/oauth/integration/implicit_grant_test.exs b/test/boruta/oauth/integration/implicit_grant_test.exs index a6eb67cb..d1f78c5d 100644 --- a/test/boruta/oauth/integration/implicit_grant_test.exs +++ b/test/boruta/oauth/integration/implicit_grant_test.exs @@ -204,6 +204,31 @@ defmodule Boruta.OauthTest.ImplicitGrantTest do ) end + test "returns an error with anonymous clients (wallets)", %{client: client} do + resource_owner = %ResourceOwner{sub: "did:key:test"} + redirect_uri = List.first(client.redirect_uris) + + assert {:authorize_error, + %Error{ + redirect_uri: "https://redirect.uri", + error: :invalid_resource_owner, + error_description: "Resource owner is invalid.", + format: :fragment, + status: :unauthorized + }} = + Oauth.authorize( + %Plug.Conn{ + query_params: %{ + "response_type" => "token", + "client_id" => client.id, + "redirect_uri" => redirect_uri + } + }, + resource_owner, + ApplicationMock + ) + end + test "returns a token", %{client: client, resource_owner: resource_owner} do redirect_uri = List.first(client.redirect_uris) diff --git a/test/boruta/openid/integration/direct_post_test.exs b/test/boruta/openid/integration/direct_post_test.exs index f6c52f26..2bc5f763 100644 --- a/test/boruta/openid/integration/direct_post_test.exs +++ b/test/boruta/openid/integration/direct_post_test.exs @@ -2,14 +2,28 @@ defmodule Boruta.OpenidTest.DirectPostTest do use Boruta.DataCase import Boruta.Factory + import Mox alias Boruta.Ecto.Client alias Boruta.Oauth + alias Boruta.Oauth.ResourceOwner alias Boruta.Openid alias Boruta.Openid.ApplicationMock alias Boruta.Openid.VerifiablePresentations alias Boruta.Repo + setup do + stub(Boruta.Support.ResourceOwners, :from_holder, fn %{sub: sub} -> + {:ok, %ResourceOwner{sub: sub}} + end) + + stub(Boruta.Support.ResourceOwners, :authorized_scopes, fn _resource_owner -> + [] + end) + + :ok + end + describe "authenticates with direct post response" do setup do {:ok, client} = Repo.get_by(Client, public_client_id: Boruta.Config.issuer()) @@ -25,6 +39,7 @@ defmodule Boruta.OpenidTest.DirectPostTest do type: "code", client: client, redirect_uri: "http://redirect.uri", + relying_party_redirect_uri: "http://relying.party.redirect.uri", state: "state", sub: wallet_did, presentation_definition: %{ @@ -108,6 +123,7 @@ defmodule Boruta.OpenidTest.DirectPostTest do VerifiablePresentations.Token.generate_and_sign( %{ "exp" => :os.system_time(:second) + 10, + "sub" => "did:key:test", "vc" => %{ "validFrom" => DateTime.utc_now() |> DateTime.add(-10) |> DateTime.to_iso8601(), "type" => ["VerifiableAttestation"] @@ -332,22 +348,13 @@ defmodule Boruta.OpenidTest.DirectPostTest do ) end - test "siopv2 - returns an error with bad public client", %{ + test "siopv2 - authenticates with bad public client", %{ id_token: id_token, bad_public_client_code: code } do conn = %Plug.Conn{} - assert {:authentication_failure, - %Boruta.Oauth.Error{ - status: :bad_request, - error: :invalid_client, - error_description: - "Authorization client_id do not match vp_token signature.", - format: :query, - redirect_uri: "http://redirect.uri", - state: "state" - }} = + assert {:direct_post_success, _response} = Openid.direct_post( conn, %{ @@ -753,6 +760,7 @@ defmodule Boruta.OpenidTest.DirectPostTest do assert response.redirect_uri == code.redirect_uri assert response.code.value == code.value assert response.state == code.state + assert response.token.redirect_uri == code.relying_party_redirect_uri end test "oid4vp - authenticates with a public client", %{ diff --git a/test/boruta/openid/verifiable_presentations_test.exs b/test/boruta/openid/verifiable_presentations_test.exs index 41e63d6b..c8bb414a 100644 --- a/test/boruta/openid/verifiable_presentations_test.exs +++ b/test/boruta/openid/verifiable_presentations_test.exs @@ -35,6 +35,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do VerifiablePresentations.Token.generate_and_sign( %{ "exp" => :os.system_time(:second) + 10, + "sub" => "did:key:test", "vc" => %{ "validFrom" => DateTime.utc_now() |> DateTime.add(-10) |> DateTime.to_iso8601(), "type" => ["VerifiableAttestation"], @@ -158,7 +159,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do presentation_definition = %{ "id" => "test", - "format" => %{"jwt_vc" => %{"alg" => ["ES256'"]}, "jwt_vp" => %{"alg" => ["ES256"]}}, + "format" => %{"jwt_vc" => %{"alg" => ["ES256"]}, "jwt_vp" => %{"alg" => ["ES256"]}}, "input_descriptors" => [ %{ "id" => "test", @@ -189,7 +190,50 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do vp_token, presentation_submission, presentation_definition - ) == :ok + ) == {:ok, "did:key:test", %{"vc.test" => "pattern"}} + end + + @tag :skip + test "returns ok (vc+sd-jwt)", %{sd_vp_token: vp_token} do + presentation_submission = %{ + "id" => "test", + "definition_id" => "test", + "descriptor_map" => [ + %{ + "id" => "test", + "format" => "vc+sd-jwt", + "path" => "$" + } + ] + } + + presentation_definition = %{ + "id" => "test", + "format" => %{"vc+sd-jwt" => %{"alg" => ["ES256"]}, "jwt_vp" => %{"alg" => ["ES256"]}}, + "input_descriptors" => [ + %{ + "id" => "test", + "format" => %{"vc+sd-jwt" => %{"alg" => ["ES256"]}}, + "constraints" => %{ + "fields" => [ + %{ + "path" => ["$.name"], + "filter" => %{ + "type" => "string", + "pattern" => "not administrator" + } + } + ] + } + } + ] + } + + assert VerifiablePresentations.validate_presentation( + vp_token, + presentation_submission, + presentation_definition + ) == {:ok, nil, %{"name" => "not administrator"}} end end @@ -447,7 +491,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do } assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - :ok + {:ok, nil, %{"vc.test" => "pattern"}} end end