From 90fee4ccd63772e7446f3b156c9ce7ca47e24ee3 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 19 Sep 2024 13:58:31 +0200 Subject: [PATCH 01/16] presentation hybrid flow --- lib/boruta/adapters/ecto/codes.ex | 3 +- lib/boruta/adapters/ecto/schemas/token.ex | 9 ++++-- lib/boruta/oauth/authorization.ex | 6 ++++ lib/boruta/oauth/request/base.ex | 1 + .../oauth/requests/presentation_request.ex | 2 ++ lib/boruta/oauth/schemas/token.ex | 2 ++ lib/boruta/openid.ex | 28 ++++++++++++++++--- lib/boruta/openid/application.ex | 8 ++++-- .../applications/direct_post_application.ex | 3 +- lib/boruta/openid/verifiable_presentations.ex | 17 ++++++----- ...240919135309_relying_party_redirect_uri.ex | 14 ++++++++++ ...ing_party_redirect_uri_to_oauth_tokens.exs | 9 ++++++ .../openid/integration/direct_post_test.exs | 3 ++ .../openid/verifiable_presentations_test.exs | 4 +-- .../support/boruta/openid/application_mock.ex | 2 +- 15 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 priv/boruta/migrations/20240919135309_relying_party_redirect_uri.ex create mode 100644 priv/repo/migrations/20240919113816_add_relying_party_redirect_uri_to_oauth_tokens.exs diff --git a/lib/boruta/adapters/ecto/codes.ex b/lib/boruta/adapters/ecto/codes.ex index 06c281e4..c53be538 100644 --- a/lib/boruta/adapters/ecto/codes.ex +++ b/lib/boruta/adapters/ecto/codes.ex @@ -111,7 +111,8 @@ defmodule Boruta.Ecto.Codes do code_challenge_method: code_challenge_method, authorization_details: authorization_details, presentation_definition: params[:presentation_definition], - public_client_id: params[:public_client_id] + public_client_id: params[:public_client_id], + relying_party_redirect_uri: params[:relying_party_redirect_uri] } ]) diff --git a/lib/boruta/adapters/ecto/schemas/token.ex b/lib/boruta/adapters/ecto/schemas/token.ex index 4ab5f64f..580addaa 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,8 @@ defmodule Boruta.Ecto.Token do :nonce, :scope, :authorization_details, - :presentation_definition + :presentation_definition, + :relying_party_redirect_uri ]) |> validate_required([:authorization_code_ttl, :client_id, :sub, :redirect_uri]) |> foreign_key_constraint(:client_id) @@ -284,7 +288,8 @@ defmodule Boruta.Ecto.Token do :code_challenge, :code_challenge_method, :authorization_details, - :presentation_definition + :presentation_definition, + :relying_party_redirect_uri ]) |> validate_required([ :authorization_code_ttl, diff --git a/lib/boruta/oauth/authorization.ex b/lib/boruta/oauth/authorization.ex index 46d5f96a..946ab06f 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(), @@ -961,6 +963,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do client_id: client_id, resource_owner: resource_owner, redirect_uri: redirect_uri, + relying_party_redirect_uri: relying_party_redirect_uri, state: state, nonce: nonce, scope: scope, @@ -1015,6 +1018,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do presentation_definition: presentation_definition, redirect_uri: redirect_uri, public_client_id: client_id, + relying_party_redirect_uri: relying_party_redirect_uri, client: client, sub: resource_owner.sub, scope: scope, @@ -1046,6 +1050,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do presentation_definition: presentation_definition, redirect_uri: redirect_uri, public_client_id: public_client_id, + relying_party_redirect_uri: relying_party_redirect_uri, client: client, sub: sub, scope: scope, @@ -1062,6 +1067,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do client: client, public_client_id: public_client_id, redirect_uri: redirect_uri, + relying_party_redirect_uri: relying_party_redirect_uri, sub: sub, scope: scope, state: state, diff --git a/lib/boruta/oauth/request/base.ex b/lib/boruta/oauth/request/base.ex index 67a4f650..15e9b84e 100644 --- a/lib/boruta/oauth/request/base.ex +++ b/lib/boruta/oauth/request/base.ex @@ -130,6 +130,7 @@ 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"], diff --git a/lib/boruta/oauth/requests/presentation_request.ex b/lib/boruta/oauth/requests/presentation_request.ex index 513ce7e4..e55465a5 100644 --- a/lib/boruta/oauth/requests/presentation_request.ex +++ b/lib/boruta/oauth/requests/presentation_request.ex @@ -10,6 +10,7 @@ defmodule Boruta.Oauth.PresentationRequest do client_id: String.t(), 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(), @@ -26,6 +27,7 @@ defmodule Boruta.Oauth.PresentationRequest do defstruct client_id: 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..7f607f34 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -24,6 +24,7 @@ defmodule Boruta.Openid do > The definition of those callbacks are provided by either `Boruta.Openid.Application` or `Boruta.Openid.JwksApplication` and `Boruta.Openid.UserinfoApplication` """ + alias Boruta.AccessTokensAdapter alias Boruta.ClientsAdapter alias Boruta.CodesAdapter alias Boruta.CredentialsAdapter @@ -32,6 +33,7 @@ defmodule Boruta.Openid do alias Boruta.Oauth.BearerToken alias Boruta.Oauth.Client alias Boruta.Oauth.Error + alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.Token alias Boruta.Openid.Credential alias Boruta.Openid.CredentialResponse @@ -161,10 +163,27 @@ 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, _code} <- CodesAdapter.revoke(code) do + {:ok, _code} <- CodesAdapter.revoke(code), + {:ok, token} <- + AccessTokensAdapter.create( + %{ + client: code.client, + resource_owner: %ResourceOwner{ + sub: sub, + extra_claims: presentation_claims + }, + redirect_uri: code.relying_party_redirect_uri, + sub: sub, + scope: code.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 @@ -290,6 +309,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 +326,8 @@ defmodule Boruta.Openid do presentation_submission, presentation_definition ) do - :ok -> - :ok + {:ok, sub, claims} -> + {:ok, sub, claims} {:error, error} -> error = %Error{ @@ -345,7 +365,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..6a4d0652 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,8 @@ defmodule Boruta.Openid.Application do any() @callback direct_post_success( conn :: Plug.Conn.t() | map(), - response :: any() + response :: any(), + token :: Boruta.Oauth.Token.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..2140b79e 100644 --- a/lib/boruta/openid/applications/direct_post_application.ex +++ b/lib/boruta/openid/applications/direct_post_application.ex @@ -6,7 +6,8 @@ defmodule Boruta.Openid.DirectPostApplication do @callback direct_post_success( conn :: Plug.Conn.t() | map(), - response :: any() + response :: any(), + token :: Boruta.Oauth.Token.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/verifiable_presentations.ex b/lib/boruta/openid/verifiable_presentations.ex index ff3081ff..a90ce120 100644 --- a/lib/boruta/openid/verifiable_presentations.ex +++ b/lib/boruta/openid/verifiable_presentations.ex @@ -62,7 +62,7 @@ defmodule Boruta.Openid.VerifiablePresentations do 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 @@ -100,8 +100,9 @@ defmodule Boruta.Openid.VerifiablePresentations 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 +178,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) 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/openid/integration/direct_post_test.exs b/test/boruta/openid/integration/direct_post_test.exs index f6c52f26..6e0ba2f1 100644 --- a/test/boruta/openid/integration/direct_post_test.exs +++ b/test/boruta/openid/integration/direct_post_test.exs @@ -25,6 +25,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: %{ @@ -375,6 +376,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 "siopv2 - authenticates with public client", %{ @@ -753,6 +755,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..3dcb249e 100644 --- a/test/boruta/openid/verifiable_presentations_test.exs +++ b/test/boruta/openid/verifiable_presentations_test.exs @@ -189,7 +189,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do vp_token, presentation_submission, presentation_definition - ) == :ok + ) == {:ok, nil, %{"vc.test" => "pattern"}} end end @@ -447,7 +447,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do } assert VerifiablePresentations.validate_credential(credential, descriptor, "jwt_vc") == - :ok + {:ok, nil, %{"vc.test" => "pattern"}} end end diff --git a/test/support/boruta/openid/application_mock.ex b/test/support/boruta/openid/application_mock.ex index e0a8990c..769971df 100644 --- a/test/support/boruta/openid/application_mock.ex +++ b/test/support/boruta/openid/application_mock.ex @@ -24,7 +24,7 @@ defmodule Boruta.Openid.ApplicationMock do def credential_failure(_conn, error), do: {:credential_failure, error} @impl Boruta.Openid.Application - def direct_post_success(_conn, response), do: {:direct_post_success, response} + def direct_post_success(_conn, response, token), do: {:direct_post_success, response, token} @impl Boruta.Openid.Application def code_not_found(_conn), do: {:code_not_found} From 6c1577b14db7be9a2044f9b6813b7e8788c982a6 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 21 Nov 2024 13:24:31 +0100 Subject: [PATCH 02/16] fix anonymous resource owners implicit authorization --- lib/boruta/oauth/authorization.ex | 10 ++++---- .../oauth/authorization/resource_owner.ex | 10 ++++++++ .../oauth/integration/implicit_grant_test.exs | 25 +++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/lib/boruta/oauth/authorization.ex b/lib/boruta/oauth/authorization.ex index 946ab06f..2df9ee2c 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -272,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 @@ -954,6 +952,7 @@ 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 @@ -1020,7 +1019,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do public_client_id: client_id, relying_party_redirect_uri: relying_party_redirect_uri, client: client, - sub: resource_owner.sub, + sub: client_id, scope: scope, state: state, nonce: nonce, @@ -1064,6 +1063,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do preauthorize(request) do with {:ok, code} <- CodesAdapter.create(%{ + resource_owner: %ResourceOwner{sub: sub}, client: client, public_client_id: public_client_id, redirect_uri: redirect_uri, 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/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) From f362a0eccd2dfedab68ecdfb4917ad5b07a99781 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Wed, 27 Nov 2024 09:51:05 +0100 Subject: [PATCH 03/16] refactor direct post handler + vc+sd-jwt support --- lib/boruta/oauth/authorization.ex | 2 + lib/boruta/oauth/contexts/resource_owners.ex | 5 +++ lib/boruta/openid.ex | 27 +++++++---- lib/boruta/openid/json/schema.ex | 6 +-- lib/boruta/openid/verifiable_presentations.ex | 30 +++++++++++++ .../openid/integration/direct_post_test.exs | 14 ++++++ .../openid/verifiable_presentations_test.exs | 45 ++++++++++++++++++- 7 files changed, 116 insertions(+), 13 deletions(-) diff --git a/lib/boruta/oauth/authorization.ex b/lib/boruta/oauth/authorization.ex index 2df9ee2c..3af23cac 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -979,6 +979,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do scope, resource_owner.presentation_configuration ), + # TODO preform a relying_party_redirect_uri verification {:ok, client} <- (case client_id do "did:" <> _key -> @@ -1061,6 +1062,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do response_mode: response_mode }} <- preauthorize(request) do + # TODO create a presentation specific code with {:ok, code} <- CodesAdapter.create(%{ resource_owner: %ResourceOwner{sub: sub}, 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/openid.ex b/lib/boruta/openid.ex index 7f607f34..0b45b834 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -24,6 +24,8 @@ 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 @@ -33,7 +35,6 @@ defmodule Boruta.Openid do alias Boruta.Oauth.BearerToken alias Boruta.Oauth.Client alias Boruta.Oauth.Error - alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.Token alias Boruta.Openid.Credential alias Boruta.Openid.CredentialResponse @@ -162,19 +163,27 @@ 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), + {: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: %{resource_owner: resource_owner} + ), {:ok, token} <- AccessTokensAdapter.create( %{ client: code.client, - resource_owner: %ResourceOwner{ - sub: sub, - extra_claims: presentation_claims - }, + resource_owner: resource_owner, redirect_uri: code.relying_party_redirect_uri, - sub: sub, - scope: code.scope, + sub: resource_owner.sub, + scope: scope, state: code.state, previous_code: code.value }, @@ -217,7 +226,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} 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/verifiable_presentations.ex b/lib/boruta/openid/verifiable_presentations.ex index a90ce120..d935b701 100644 --- a/lib/boruta/openid/verifiable_presentations.ex +++ b/lib/boruta/openid/verifiable_presentations.ex @@ -95,6 +95,35 @@ 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}} <- 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, claims["sub"], filtered_claims} + end + end + end def validate_credential(credential, descriptor, "jwt_vc") do with {:ok, _jwk, claims} <- validate_signature(credential), @@ -227,6 +256,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/test/boruta/openid/integration/direct_post_test.exs b/test/boruta/openid/integration/direct_post_test.exs index 6e0ba2f1..579a0dac 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()) diff --git a/test/boruta/openid/verifiable_presentations_test.exs b/test/boruta/openid/verifiable_presentations_test.exs index 3dcb249e..73c8923f 100644 --- a/test/boruta/openid/verifiable_presentations_test.exs +++ b/test/boruta/openid/verifiable_presentations_test.exs @@ -158,7 +158,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", @@ -191,6 +191,49 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do presentation_definition ) == {:ok, nil, %{"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 describe "validate_credential/3" do From 2f374174511502ab3bce6ca595d7d1eeb218d5e6 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 27 Feb 2025 09:36:10 +0100 Subject: [PATCH 04/16] reenable code revocation on direct post --- lib/boruta/openid/responses/direct_post.ex | 2 ++ 1 file changed, 2 insertions(+) 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 } From 7955f6c5c08c14e9f55e3251c11d4961a4baa32a Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 20 Mar 2025 16:32:42 +0100 Subject: [PATCH 05/16] fix rebase --- lib/boruta/openid/verifiable_credentials.ex | 1 + lib/boruta/openid/verifiable_presentations.ex | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) 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 d935b701..f1cc115e 100644 --- a/lib/boruta/openid/verifiable_presentations.ex +++ b/lib/boruta/openid/verifiable_presentations.ex @@ -52,13 +52,13 @@ 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 @@ -66,7 +66,10 @@ defmodule Boruta.Openid.VerifiablePresentations do {: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, ", ")} From ae1e43f67db8b60b34f4d4e688913e13a5ea444b Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Fri, 21 Mar 2025 09:37:59 +0100 Subject: [PATCH 06/16] take sub from credential in vc+sd-jwt presentations --- lib/boruta/openid/verifiable_presentations.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/boruta/openid/verifiable_presentations.ex b/lib/boruta/openid/verifiable_presentations.ex index f1cc115e..93f33a49 100644 --- a/lib/boruta/openid/verifiable_presentations.ex +++ b/lib/boruta/openid/verifiable_presentations.ex @@ -102,7 +102,7 @@ defmodule Boruta.Openid.VerifiablePresentations do def validate_credential(credential, descriptor, "vc+sd-jwt") do [credential|disclosures] = String.split(credential, "~") - with {:ok, _jwk, %{"_sd" => sd}} <- validate_signature(credential) do + 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), @@ -123,7 +123,7 @@ defmodule Boruta.Openid.VerifiablePresentations do |> Enum.into(%{}) with {:ok, filtered_claims} <- validate_constraints(claims, descriptor) do - {:ok, claims["sub"], filtered_claims} + {:ok, sub, filtered_claims} end end end From 7695561e3bf68bb129c6a5948adacc75786f032d Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Tue, 25 Mar 2025 18:29:49 +0100 Subject: [PATCH 07/16] fix integration tests --- .../oauth/integration/authorization_code_grant_test.exs | 4 ++-- test/boruta/openid/integration/direct_post_test.exs | 1 + test/boruta/openid/verifiable_presentations_test.exs | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/boruta/oauth/integration/authorization_code_grant_test.exs b/test/boruta/oauth/integration/authorization_code_grant_test.exs index ab94d820..0154092d 100644 --- a/test/boruta/oauth/integration/authorization_code_grant_test.exs +++ b/test/boruta/oauth/integration/authorization_code_grant_test.exs @@ -980,7 +980,7 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do "scope" => "openid" } }, - %ResourceOwner{sub: "did:key:test"}, + %ResourceOwner{sub: "sub"}, ApplicationMock ) @@ -1042,7 +1042,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/openid/integration/direct_post_test.exs b/test/boruta/openid/integration/direct_post_test.exs index 579a0dac..190fde05 100644 --- a/test/boruta/openid/integration/direct_post_test.exs +++ b/test/boruta/openid/integration/direct_post_test.exs @@ -123,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"] diff --git a/test/boruta/openid/verifiable_presentations_test.exs b/test/boruta/openid/verifiable_presentations_test.exs index 73c8923f..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"], @@ -189,7 +190,7 @@ defmodule Boruta.Openid.VerifiablePresentationsTest do vp_token, presentation_submission, presentation_definition - ) == {:ok, nil, %{"vc.test" => "pattern"}} + ) == {:ok, "did:key:test", %{"vc.test" => "pattern"}} end @tag :skip From 4a072e0a3e3ce873a1835a00b7fdec111c63f1d7 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Tue, 25 Mar 2025 19:18:59 +0100 Subject: [PATCH 08/16] validate client relying party redirect uri on presentation --- lib/boruta/oauth/authorization.ex | 17 +++- lib/boruta/oauth/authorization/client.ex | 3 +- .../authorization_code_grant_test.exs | 84 ++++++++++++++++++- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/lib/boruta/oauth/authorization.ex b/lib/boruta/oauth/authorization.ex index 3af23cac..1dc90011 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -979,11 +979,11 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do scope, resource_owner.presentation_configuration ), - # TODO preform a relying_party_redirect_uri verification + # TODO perform public client redirect_uri check {:ok, client} <- (case client_id do "did:" <> _key -> - {:ok, ClientsAdapter.public!()} + {:ok, ClientsAdapter.public!()} _ -> Authorization.Client.authorize( @@ -993,6 +993,19 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do grant_type: response_type ) end), + {: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, resource_owner} <- (case client_id do "did:" <> _key -> {:ok, resource_owner} 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/test/boruta/oauth/integration/authorization_code_grant_test.exs b/test/boruta/oauth/integration/authorization_code_grant_test.exs index 0154092d..0d4c598e 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:" @@ -956,7 +989,8 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do 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,6 +1009,7 @@ 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" @@ -989,6 +1024,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) From ee369d093026bed4b81393634e95cbc39fc44c29 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Sat, 5 Apr 2025 11:51:40 +0200 Subject: [PATCH 09/16] update changelog --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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 From 8e197043381c6e037a4237d51a37e2ff5e627c3f Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 5 Jun 2025 23:10:22 +0200 Subject: [PATCH 10/16] fix rebase --- lib/boruta/openid/application.ex | 3 +-- lib/boruta/openid/applications/direct_post_application.ex | 3 +-- test/support/boruta/openid/application_mock.ex | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/boruta/openid/application.ex b/lib/boruta/openid/application.ex index 6a4d0652..d32716e8 100644 --- a/lib/boruta/openid/application.ex +++ b/lib/boruta/openid/application.ex @@ -51,8 +51,7 @@ defmodule Boruta.Openid.Application do any() @callback direct_post_success( conn :: Plug.Conn.t() | map(), - response :: any(), - token :: Boruta.Oauth.Token.t() + 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 2140b79e..1342e0ef 100644 --- a/lib/boruta/openid/applications/direct_post_application.ex +++ b/lib/boruta/openid/applications/direct_post_application.ex @@ -6,8 +6,7 @@ defmodule Boruta.Openid.DirectPostApplication do @callback direct_post_success( conn :: Plug.Conn.t() | map(), - response :: any(), - token :: Boruta.Oauth.Token.t() + 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/test/support/boruta/openid/application_mock.ex b/test/support/boruta/openid/application_mock.ex index 769971df..e0a8990c 100644 --- a/test/support/boruta/openid/application_mock.ex +++ b/test/support/boruta/openid/application_mock.ex @@ -24,7 +24,7 @@ defmodule Boruta.Openid.ApplicationMock do def credential_failure(_conn, error), do: {:credential_failure, error} @impl Boruta.Openid.Application - def direct_post_success(_conn, response, token), do: {:direct_post_success, response, token} + def direct_post_success(_conn, response), do: {:direct_post_success, response} @impl Boruta.Openid.Application def code_not_found(_conn), do: {:code_not_found} From 9e8c72f118f58230c9b6ae30f39512118990fa97 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 5 Jun 2025 23:56:53 +0200 Subject: [PATCH 11/16] remove token creation from siopv2 direct post --- lib/boruta/openid.ex | 100 ++++++++++++------ .../openid/integration/direct_post_test.exs | 1 - 2 files changed, 66 insertions(+), 35 deletions(-) diff --git a/lib/boruta/openid.ex b/lib/boruta/openid.ex index 0b45b834..464be166 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -163,40 +163,72 @@ defmodule Boruta.Openid do }), :ok <- maybe_check_public_client_id(direct_post_params, code.public_client_id, code.client), - {:ok, sub, presentation_claims} <- maybe_check_presentation(direct_post_params, code.presentation_definition), - {:ok, _code} <- CodesAdapter.revoke(code), - {: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: %{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 - }) + {:ok, sub, presentation_claims} <- + maybe_check_presentation(direct_post_params, code.presentation_definition), + {:ok, _code} <- CodesAdapter.revoke(code) do + 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: %{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{ diff --git a/test/boruta/openid/integration/direct_post_test.exs b/test/boruta/openid/integration/direct_post_test.exs index 190fde05..46796277 100644 --- a/test/boruta/openid/integration/direct_post_test.exs +++ b/test/boruta/openid/integration/direct_post_test.exs @@ -391,7 +391,6 @@ 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 "siopv2 - authenticates with public client", %{ From 278d29133849dd42c4c4706f6e4debc11300104d Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Fri, 6 Jun 2025 01:21:29 +0200 Subject: [PATCH 12/16] remove authentication for presentation requests --- lib/boruta/oauth/authorization.ex | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/boruta/oauth/authorization.ex b/lib/boruta/oauth/authorization.ex index 1dc90011..9c0a2d85 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -1006,11 +1006,6 @@ 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) - end), :ok <- Authorization.Nonce.authorize(request), :ok <- VerifiableCredentials.validate_authorization_details(authorization_details), :ok <- VerifiablePresentations.check_client_metadata(client_metadata), From 08c32678f845f11e77986ac2567711c3d8e88026 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Fri, 6 Jun 2025 22:32:48 +0200 Subject: [PATCH 13/16] verify scopes against code client in hybrid presentation --- lib/boruta/openid.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/boruta/openid.ex b/lib/boruta/openid.ex index 464be166..005d7d90 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -186,7 +186,7 @@ defmodule Boruta.Openid do {:ok, scope} <- Authorization.Scope.authorize( scope: code.scope, - against: %{resource_owner: resource_owner} + against: %{client: code.client, resource_owner: resource_owner} ), {:ok, token} <- AccessTokensAdapter.create( From e7f313388867aee71605284ee1e3ebe18c2d7d39 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Sat, 7 Jun 2025 01:30:46 +0200 Subject: [PATCH 14/16] store presentation previous code --- lib/boruta/adapters/ecto/codes.ex | 3 +- lib/boruta/adapters/ecto/schemas/token.ex | 6 +- lib/boruta/oauth/authorization.ex | 56 ++++++++++--------- lib/boruta/oauth/request/base.ex | 1 + .../oauth/requests/presentation_request.ex | 2 + .../authorization_code_grant_test.exs | 40 +++++++++++++ 6 files changed, 79 insertions(+), 29 deletions(-) diff --git a/lib/boruta/adapters/ecto/codes.ex b/lib/boruta/adapters/ecto/codes.ex index c53be538..7bc47777 100644 --- a/lib/boruta/adapters/ecto/codes.ex +++ b/lib/boruta/adapters/ecto/codes.ex @@ -112,7 +112,8 @@ defmodule Boruta.Ecto.Codes do authorization_details: authorization_details, presentation_definition: params[:presentation_definition], public_client_id: params[:public_client_id], - relying_party_redirect_uri: params[:relying_party_redirect_uri] + 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 580addaa..8cf0b94f 100644 --- a/lib/boruta/adapters/ecto/schemas/token.ex +++ b/lib/boruta/adapters/ecto/schemas/token.ex @@ -264,7 +264,8 @@ defmodule Boruta.Ecto.Token do :scope, :authorization_details, :presentation_definition, - :relying_party_redirect_uri + :relying_party_redirect_uri, + :previous_code ]) |> validate_required([:authorization_code_ttl, :client_id, :sub, :redirect_uri]) |> foreign_key_constraint(:client_id) @@ -289,7 +290,8 @@ defmodule Boruta.Ecto.Token do :code_challenge_method, :authorization_details, :presentation_definition, - :relying_party_redirect_uri + :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 9c0a2d85..51ea2c5c 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -959,18 +959,19 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do def preauthorize( %PresentationRequest{ + authorization_details: authorization_details, client_id: client_id, - resource_owner: resource_owner, + client_metadata: client_metadata, + code: code, + code_challenge: code_challenge, + code_challenge_method: code_challenge_method, + nonce: nonce, redirect_uri: redirect_uri, relying_party_redirect_uri: relying_party_redirect_uri, - state: state, - nonce: nonce, + resource_owner: resource_owner, + response_type: response_type, scope: scope, - code_challenge: code_challenge, - code_challenge_method: code_challenge_method, - authorization_details: authorization_details, - client_metadata: client_metadata, - response_type: response_type + state: state } = request ) do with [response_type] = response_types <- @@ -1022,20 +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, + redirect_uri: redirect_uri, relying_party_redirect_uri: relying_party_redirect_uri, - client: client, - sub: client_id, + 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} -> @@ -1054,20 +1056,21 @@ 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, + redirect_uri: redirect_uri, relying_party_redirect_uri: relying_party_redirect_uri, - client: client, - sub: sub, + 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 @@ -1078,6 +1081,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do 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/request/base.ex b/lib/boruta/oauth/request/base.ex index 15e9b84e..30a82210 100644 --- a/lib/boruta/oauth/request/base.ex +++ b/lib/boruta/oauth/request/base.ex @@ -136,6 +136,7 @@ defmodule Boruta.Oauth.Request.Base do prompt: params["prompt"], code_challenge: params["code_challenge"], code_challenge_method: params["code_challenge_method"], + code: params["code"], scope: params["scope"], client_metadata: client_metadata, response_type: params["response_type"] diff --git a/lib/boruta/oauth/requests/presentation_request.ex b/lib/boruta/oauth/requests/presentation_request.ex index e55465a5..373af0bc 100644 --- a/lib/boruta/oauth/requests/presentation_request.ex +++ b/lib/boruta/oauth/requests/presentation_request.ex @@ -8,6 +8,7 @@ defmodule Boruta.Oauth.PresentationRequest do """ @type t :: %__MODULE__{ client_id: String.t(), + code: String.t() | nil, resource_owner: Boruta.Oauth.ResourceOwner.t(), redirect_uri: String.t(), relying_party_redirect_uri: String.t(), @@ -25,6 +26,7 @@ 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, diff --git a/test/boruta/oauth/integration/authorization_code_grant_test.exs b/test/boruta/oauth/integration/authorization_code_grant_test.exs index 0d4c598e..6affee4c 100644 --- a/test/boruta/oauth/integration/authorization_code_grant_test.exs +++ b/test/boruta/oauth/integration/authorization_code_grant_test.exs @@ -987,6 +987,46 @@ defmodule Boruta.OauthTest.AuthorizationCodeGrantTest do ~r"#{redirect_uri}" end + test "returns a code with siopv2 - previous_code (direct_post)" do + redirect_uri = "openid:" + code = "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://" relying_party_redirect_uri = "https://redirect.uri" From fe462de6dec8553d5209f5601484a738a7c8b803 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Sat, 7 Jun 2025 03:13:58 +0200 Subject: [PATCH 15/16] do not check public client id in siopv2 --- lib/boruta/openid.ex | 24 +++++-------------- .../openid/integration/direct_post_test.exs | 13 ++-------- 2 files changed, 8 insertions(+), 29 deletions(-) diff --git a/lib/boruta/openid.ex b/lib/boruta/openid.ex index 005d7d90..4d25bc7c 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -302,13 +302,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} -> @@ -322,23 +322,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 diff --git a/test/boruta/openid/integration/direct_post_test.exs b/test/boruta/openid/integration/direct_post_test.exs index 46796277..2bc5f763 100644 --- a/test/boruta/openid/integration/direct_post_test.exs +++ b/test/boruta/openid/integration/direct_post_test.exs @@ -348,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, %{ From 824e9ce5766b6e93a8b702482aeb7943ba79e173 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Sat, 28 Feb 2026 01:57:23 +0100 Subject: [PATCH 16/16] return code in direct post error responses --- lib/boruta/oauth/error.ex | 6 ++++-- lib/boruta/openid.ex | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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/openid.ex b/lib/boruta/openid.ex index 4d25bc7c..f5ed8f73 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -237,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} -> @@ -245,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