• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

malach-it / boruta_auth / 954161a5b397cc94069ec938dc16fcb785e37074-PR-29

18 Jan 2025 10:28PM UTC coverage: 85.651% (-4.3%) from 89.944%
954161a5b397cc94069ec938dc16fcb785e37074-PR-29

Pull #29

github

patatoid
refactor verifiable credentials status tokens
Pull Request #29: Agent credentials PoC

188 of 304 new or added lines in 20 files covered. (61.84%)

3 existing lines in 1 file now uncovered.

1552 of 1812 relevant lines covered (85.65%)

85.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.86
/lib/boruta/adapters/ecto/schemas/client.ex
1
defmodule Boruta.Ecto.Client do
2
  @moduledoc """
3
  Ecto Adapter Client Schema
4
  """
5

6
  use Ecto.Schema
7

8
  import Ecto.Changeset
9

10
  import Boruta.Config,
11
    only: [
12
      token_generator: 0,
13
      repo: 0,
14
      access_token_max_ttl: 0,
15
      agent_token_max_ttl: 0,
16
      authorization_code_max_ttl: 0,
17
      authorization_request_max_ttl: 0,
18
      id_token_max_ttl: 0,
19
      refresh_token_max_ttl: 0
20
    ]
21

22
  alias Boruta.Did
23
  alias Boruta.Ecto.Scope
24
  alias Boruta.Oauth
25
  alias Boruta.Oauth.Client
26
  alias Boruta.Universal
27
  alias ExJsonSchema.Validator.Error.BorutaFormatter
28

29
  @type t :: %__MODULE__{
30
          secret: String.t(),
31
          authorize_scope: boolean(),
32
          redirect_uris: list(String.t()),
33
          supported_grant_types: list(String.t()),
34
          enforce_dpop: boolean(),
35
          enforce_tx_code: boolean(),
36
          pkce: boolean(),
37
          public_refresh_token: boolean(),
38
          public_revoke: boolean(),
39
          access_token_ttl: integer(),
40
          agent_token_ttl: integer(),
41
          authorization_code_ttl: integer(),
42
          refresh_token_ttl: integer(),
43
          authorized_scopes: Ecto.Association.NotLoaded.t() | list(Scope.t()),
44
          id_token_ttl: integer(),
45
          id_token_signature_alg: String.t(),
46
          token_endpoint_auth_methods: list(String.t()),
47
          token_endpoint_jwt_auth_alg: String.t(),
48
          userinfo_signed_response_alg: String.t() | nil,
49
          jwt_public_key: String.t(),
50
          public_key: String.t(),
51
          private_key: String.t(),
52
          response_mode: String.t(),
53
          signatures_adapter: String.t()
54
        }
55

56
  @token_endpoint_auth_methods [
57
    "client_secret_basic",
58
    "client_secret_post",
59
    "client_secret_jwt",
60
    "private_key_jwt"
61
  ]
62

63
  @token_endpoint_jwt_auth_algs [
64
    :RS256,
65
    :RS384,
66
    :RS512,
67
    :HS256,
68
    :HS384,
69
    :HS512
70
  ]
71

72
  @response_modes ["post", "direct_post"]
73

74
  @key_pair_type_schema %{
75
    "type" => "object",
76
    "properties" => %{
77
      "type" => %{"type" => "string", "pattern" => "^ec|rsa|universal$"},
78
      "modulus_size" => %{"type" => "string"},
79
      "exponent_size" => %{"type" => "string"},
80
      "curve" => %{"type" => "string", "pattern" => "^P-256|P-384|P-512$"}
81
    },
82
    "required" => ["type"]
83
  }
84

85
  @key_pair_type_jwt_algs %{
86
    "ec" => [
87
      "ES256",
88
      "ES384",
89
      "ES512",
90
      "HS256",
91
      "HS384",
92
      "HS512"
93
    ],
94
    "rsa" => [
95
      "RS256",
96
      "RS384",
97
      "RS512",
98
      "HS256",
99
      "HS384",
100
      "HS512"
101
    ],
102
    "universal" => [
103
      "EdDSA"
104
    ]
105
  }
106

107
  @primary_key {:id, Ecto.UUID, autogenerate: true}
108
  @foreign_key_type :binary_id
109
  @timestamps_opts type: :utc_datetime
110
  schema "oauth_clients" do
6,539✔
111
    field(:public_client_id, :string)
112
    field(:name, :string)
113
    field(:secret, :string)
114
    field(:confidential, :boolean, default: false)
115
    field(:authorize_scope, :boolean, default: false)
116
    field(:enforce_tx_code, :boolean, default: false)
117
    field(:enforce_dpop, :boolean, default: false)
118
    field(:redirect_uris, {:array, :string}, default: [])
119

120
    field(:supported_grant_types, {:array, :string}, default: Oauth.Client.grant_types())
121

122
    field(:pkce, :boolean, default: false)
123
    field(:public_refresh_token, :boolean, default: false)
124
    field(:public_revoke, :boolean, default: false)
125

126
    field(:access_token_ttl, :integer)
127
    field(:agent_token_ttl, :integer)
128
    field(:authorization_code_ttl, :integer)
129
    field(:authorization_request_ttl, :integer)
130
    field(:id_token_ttl, :integer)
131
    field(:refresh_token_ttl, :integer)
132

133
    field(:id_token_signature_alg, :string, default: "RS512")
134
    field(:id_token_kid, :string)
135

136
    field(:signatures_adapter, :string, default: "Elixir.Boruta.Internal.Signatures")
137

138
    field(:key_pair_type, :map,
139
      default: %{
140
        "type" => "rsa",
141
        "modulus_size" => "1024",
142
        "exponent_size" => "65537"
143
      }
144
    )
145

146
    field(:public_key, :string)
147
    field(:private_key, :string)
148
    field(:did, :string)
149

150
    field(:token_endpoint_auth_methods, {:array, :string},
151
      default: ["client_secret_basic", "client_secret_post"]
152
    )
153

154
    field(:token_endpoint_jwt_auth_alg, :string, default: "HS256")
155
    field(:jwt_public_key, :string)
156
    field(:jwk, :map, virtual: true)
157
    field(:jwks_uri, :string)
158

159
    field(:userinfo_signed_response_alg, :string)
160

161
    field(:logo_uri, :string)
162
    field(:metadata, :map, default: %{})
163

164
    field(:response_mode, :string, default: "direct_post")
165

166
    many_to_many :authorized_scopes, Scope,
167
      join_through: "oauth_clients_scopes",
168
      on_replace: :delete
169

170
    timestamps()
171
  end
172

173
  def create_changeset(client, attrs) do
174
    client
175
    |> repo().preload(:authorized_scopes)
176
    |> cast(attrs, [
177
      :id,
178
      :name,
179
      :secret,
180
      :confidential,
181
      :access_token_ttl,
182
      :agent_token_ttl,
183
      :authorization_code_ttl,
184
      :authorization_request_ttl,
185
      :refresh_token_ttl,
186
      :id_token_ttl,
187
      :redirect_uris,
188
      :authorize_scope,
189
      :enforce_dpop,
190
      :enforce_tx_code,
191
      :supported_grant_types,
192
      :token_endpoint_auth_methods,
193
      :token_endpoint_jwt_auth_alg,
194
      :jwk,
195
      :jwks_uri,
196
      :jwt_public_key,
197
      :pkce,
198
      :public_refresh_token,
199
      :public_revoke,
200
      :id_token_signature_alg,
201
      :id_token_kid,
202
      :userinfo_signed_response_alg,
203
      :logo_uri,
204
      :metadata,
205
      :response_mode,
206
      :signatures_adapter,
207
      :key_pair_type,
208
    ])
209
    |> validate_required([:redirect_uris, :key_pair_type])
210
    |> unique_constraint(:id, name: :clients_pkey)
211
    |> change_access_token_ttl()
212
    |> change_agent_token_ttl()
213
    |> change_authorization_code_ttl()
214
    |> change_authorization_request_ttl()
215
    |> change_id_token_ttl()
216
    |> change_refresh_token_ttl()
217
    |> validate_redirect_uris()
218
    |> validate_supported_grant_types()
219
    |> validate_id_token_signature_alg()
220
    |> validate_inclusion(:response_mode, @response_modes)
221
    |> validate_subset(:token_endpoint_auth_methods, @token_endpoint_auth_methods)
222
    |> validate_inclusion(
223
      :token_endpoint_jwt_auth_alg,
224
      Enum.map(@token_endpoint_jwt_auth_algs, &Atom.to_string/1)
225
    )
226
    |> validate_inclusion(
227
      :userinfo_signed_response_alg,
228
      Enum.map(Client.Crypto.signature_algorithms(), &Atom.to_string/1)
229
    )
230
    |> put_assoc(:authorized_scopes, parse_authorized_scopes(attrs))
231
    |> translate_jwk()
232
    |> validate_signatures_adapter()
233
    |> validate_key_pair_type()
234
    |> generate_key_pair()
235
    |> put_secret()
236
    |> validate_required(:secret)
62✔
237
  end
238

239
  def update_changeset(client, attrs) do
240
    client
241
    |> repo().preload(:authorized_scopes)
242
    |> cast(attrs, [
243
      :name,
244
      :secret,
245
      :confidential,
246
      :access_token_ttl,
247
      :agent_token_ttl,
248
      :authorization_code_ttl,
249
      :authorization_request_ttl,
250
      :refresh_token_ttl,
251
      :id_token_ttl,
252
      :redirect_uris,
253
      :authorize_scope,
254
      :enforce_dpop,
255
      :enforce_tx_code,
256
      :supported_grant_types,
257
      :token_endpoint_auth_methods,
258
      :token_endpoint_jwt_auth_alg,
259
      :jwk,
260
      :jwks_uri,
261
      :jwt_public_key,
262
      :pkce,
263
      :public_refresh_token,
264
      :public_revoke,
265
      :id_token_signature_alg,
266
      :id_token_kid,
267
      :userinfo_signed_response_alg,
268
      :logo_uri,
269
      :metadata,
270
      :response_mode,
271
      :signatures_adapter,
272
      :key_pair_type
273
    ])
274
    |> validate_required([
275
      :authorization_code_ttl,
276
      :access_token_ttl,
277
      :agent_token_ttl,
278
      :refresh_token_ttl,
279
      :id_token_ttl,
280
      :key_pair_type
281
    ])
282
    |> validate_inclusion(:access_token_ttl, 1..access_token_max_ttl())
283
    |> validate_inclusion(:agent_token_ttl, 1..agent_token_max_ttl())
284
    |> validate_inclusion(:authorization_code_ttl, 1..authorization_code_max_ttl())
285
    |> validate_inclusion(:access_token_ttl, 1..authorization_request_max_ttl())
286
    |> validate_inclusion(:refresh_token_ttl, 1..refresh_token_max_ttl())
287
    |> validate_inclusion(:refresh_token_ttl, 1..id_token_max_ttl())
288
    |> validate_inclusion(:response_mode, @response_modes)
289
    |> validate_subset(:token_endpoint_auth_methods, @token_endpoint_auth_methods)
290
    |> validate_inclusion(
291
      :token_endpoint_jwt_auth_alg,
292
      Enum.map(@token_endpoint_jwt_auth_algs, &Atom.to_string/1)
293
    )
294
    |> validate_inclusion(
295
      :userinfo_signed_response_alg,
296
      Enum.map(Client.Crypto.signature_algorithms(), &Atom.to_string/1)
297
    )
298
    |> validate_redirect_uris()
299
    |> validate_supported_grant_types()
300
    |> validate_id_token_signature_alg()
301
    |> put_assoc(:authorized_scopes, parse_authorized_scopes(attrs))
302
    |> validate_signatures_adapter()
303
    |> validate_key_pair_type()
304
    |> translate_jwk()
49✔
305
  end
306

307
  def secret_changeset(client, secret \\ nil) do
308
    client
309
    |> cast(%{secret: secret}, [:secret])
310
    |> put_secret()
311
    |> validate_required(:secret)
2✔
312
  end
313

314
  def did_changeset(client) do
315
    change(client)
316
    |> put_did()
×
317
  end
318

319
  def key_pair_changeset(client, attrs \\ %{}) do
320
    client
321
    |> cast(attrs, [:public_key, :private_key])
322
    |> generate_key_pair()
2✔
323
  end
324

325
  defp change_access_token_ttl(changeset) do
326
    case fetch_change(changeset, :access_token_ttl) do
62✔
327
      {:ok, _access_token_ttl} ->
328
        validate_inclusion(changeset, :access_token_ttl, 1..access_token_max_ttl())
2✔
329

330
      :error ->
331
        put_change(changeset, :access_token_ttl, access_token_max_ttl())
60✔
332
    end
333
  end
334

335
  defp change_agent_token_ttl(changeset) do
336
    case fetch_change(changeset, :agent_token_ttl) do
62✔
337
      {:ok, _agent_token_ttl} ->
NEW
338
        validate_inclusion(changeset, :agent_token_ttl, 1..agent_token_max_ttl())
×
339

340
      :error ->
341
        put_change(changeset, :agent_token_ttl, agent_token_max_ttl())
62✔
342
    end
343
  end
344

345
  defp change_authorization_code_ttl(changeset) do
346
    case fetch_change(changeset, :authorization_code_ttl) do
62✔
347
      {:ok, _authorization_code_ttl} ->
348
        validate_inclusion(changeset, :authorization_code_ttl, 1..authorization_code_max_ttl())
2✔
349

350
      :error ->
351
        put_change(changeset, :authorization_code_ttl, authorization_code_max_ttl())
60✔
352
    end
353
  end
354

355
  defp change_authorization_request_ttl(changeset) do
356
    case fetch_change(changeset, :authorization_request_ttl) do
62✔
357
      {:ok, _authorization_request_ttl} ->
358
        validate_inclusion(
×
359
          changeset,
360
          :authorization_request_ttl,
361
          1..authorization_request_max_ttl()
362
        )
363

364
      :error ->
365
        put_change(changeset, :authorization_request_ttl, authorization_request_max_ttl())
62✔
366
    end
367
  end
368

369
  defp change_refresh_token_ttl(changeset) do
370
    case fetch_change(changeset, :refresh_token_ttl) do
62✔
371
      {:ok, _access_token_ttl} ->
372
        validate_inclusion(changeset, :refresh_token_ttl, 1..refresh_token_max_ttl())
1✔
373

374
      :error ->
375
        put_change(changeset, :refresh_token_ttl, refresh_token_max_ttl())
61✔
376
    end
377
  end
378

379
  defp change_id_token_ttl(changeset) do
380
    case fetch_change(changeset, :id_token_ttl) do
62✔
381
      {:ok, _id_token_ttl} ->
382
        validate_inclusion(changeset, :id_token_ttl, 1..id_token_max_ttl())
1✔
383

384
      :error ->
385
        put_change(changeset, :id_token_ttl, id_token_max_ttl())
61✔
386
    end
387
  end
388

389
  defp validate_signatures_adapter(changeset) do
390
    key_pair_type = get_field(changeset, :key_pair_type)
111✔
391

392
    case key_pair_type do
111✔
393
      %{"type" => "universal"} ->
NEW
394
        validate_inclusion(changeset, :signatures_adapter, [Atom.to_string(Boruta.Universal.Signatures)])
×
395
      %{"type" => type} when type in ["ec", "rsa"] ->
396
        validate_inclusion(changeset, :signatures_adapter, [Atom.to_string(Boruta.Internal.Signatures)])
110✔
397
      _ ->
398
        add_error(changeset, :signatures_adapter, "unknown key pair type")
1✔
399
    end
400
  end
401

402
  defp validate_key_pair_type(changeset) do
403
    key_pair_type = get_field(changeset, :key_pair_type)
111✔
404

405
    case ExJsonSchema.Validator.validate(
111✔
406
           @key_pair_type_schema,
407
           key_pair_type,
408
           error_formatter: BorutaFormatter
409
         ) do
410
      :ok ->
411
        changeset
412
        |> validate_inclusion(
413
          :id_token_signature_alg,
414
          @key_pair_type_jwt_algs[key_pair_type["type"]]
415
        )
416
        |> validate_inclusion(
110✔
417
          :userinfo_signed_response_alg,
418
          @key_pair_type_jwt_algs[key_pair_type["type"]]
419
        )
420

421
      {:error, errors} ->
422
        add_error(changeset, :key_pair_type, "validation failed: #{Enum.join(errors, " ")}")
1✔
423
    end
424
  end
425

426
  defp validate_redirect_uris(changeset) do
427
    validate_change(changeset, :redirect_uris, fn field, values ->
111✔
428
      Enum.map(values, &validate_uri/1)
429
      |> Enum.reject(&is_nil/1)
6✔
430
      |> Enum.map(fn error -> {field, error} end)
6✔
431
    end)
432
  end
433

434
  defp validate_supported_grant_types(changeset) do
435
    server_grant_types = Oauth.Client.grant_types()
111✔
436

437
    validate_change(changeset, :supported_grant_types, fn :supported_grant_types,
111✔
438
                                                          current_grant_types ->
439
      case Enum.empty?(current_grant_types -- server_grant_types) do
33✔
440
        true -> []
32✔
441
        false -> [supported_grant_types: "must be part of #{Enum.join(server_grant_types, ", ")}"]
1✔
442
      end
443
    end)
444
  end
445

446
  defp validate_uri(nil), do: "empty values are not allowed"
×
447

448
  defp validate_uri("" <> uri) do
449
    case URI.parse(uri) do
6✔
450
      %URI{scheme: scheme, host: host, fragment: fragment}
451
      when not is_nil(scheme) and not is_nil(host) and is_nil(fragment) ->
3✔
452
        nil
453

454
      _ ->
455
        "`#{uri}` is invalid"
3✔
456
    end
457
  end
458

459
  defp validate_id_token_signature_alg(changeset) do
460
    signature_algorithms = Enum.map(Client.Crypto.signature_algorithms(), &Atom.to_string/1)
111✔
461
    validate_inclusion(changeset, :id_token_signature_alg, signature_algorithms)
111✔
462
  end
463

464
  defp parse_authorized_scopes(attrs) do
465
    Enum.map(
466
      attrs["authorized_scopes"] || attrs[:authorized_scopes] || [],
111✔
467
      fn scope_attrs ->
468
        case apply_action(Scope.assoc_changeset(%Scope{}, scope_attrs), :replace) do
5✔
469
          {:ok, %Scope{id: id}} when is_binary(id) ->
470
            repo().get_by(Scope, id: id)
2✔
471

472
          {:ok, %Scope{name: name}} when is_binary(name) ->
473
            repo().get_by(Scope, name: name) || %Scope{name: name}
2✔
474

475
          _ ->
1✔
476
            nil
477
        end
478
      end
479
    )
480
    |> Enum.reject(&is_nil/1)
111✔
481
  end
482

483
  defp translate_jwk(%Ecto.Changeset{changes: %{jwk: jwk}} = changeset) do
484
    {_key_type, pem} = JOSE.JWK.from_map(jwk) |> JOSE.JWK.to_pem()
5✔
485

486
    put_change(changeset, :jwt_public_key, pem)
5✔
487
  end
488

489
  defp translate_jwk(changeset), do: changeset
106✔
490

491
  defp generate_key_pair(%Ecto.Changeset{changes: %{private_key: _private_key}} = changeset) do
492
    changeset
1✔
493
  end
494

495
  defp generate_key_pair(changeset) do
496
    private_key =
63✔
497
      case get_field(changeset, :key_pair_type) do
498
        %{"type" => "rsa", "modulus_size" => modulus_size, "exponent_size" => exponent_size} ->
499
          JOSE.JWK.generate_key(
52✔
500
            {:rsa, String.to_integer(modulus_size), String.to_integer(exponent_size)}
501
          )
502

503
        %{"type" => "ec", "curve" => curve} ->
504
          JOSE.JWK.generate_key({:ec, curve})
8✔
505

NEW
506
        %{"type" => "universal"} ->
×
507
          "universal"
508

509
        _ ->
3✔
510
          nil
511
      end
512

513
    case private_key do
63✔
514
      nil ->
515
        add_error(changeset, :private_key, "private_key_type is invalid")
3✔
516

517
      "universal" ->
NEW
518
        with {:ok, did, jwk} <- Did.create("key"),
×
NEW
519
             {:ok, key_id} <- Universal.Signatures.SigningKey.get_key_by_did(did) do
×
NEW
520
          "did:key:" <> key = did
×
NEW
521
          public_key = JOSE.JWK.from_map(jwk)
×
NEW
522
          {_type, public_pem} = JOSE.JWK.to_pem(public_key)
×
523

524
          changeset
525
          |> put_change(:private_key, key_id["id"])
526
          |> put_change(:public_key, public_pem)
NEW
527
          |> put_change(:did, "#{did}##{key}")
×
528
          |> put_change(:signatures_adapter, Boruta.Universal.Signatures |> Atom.to_string())
529
          |> put_change(:id_token_signature_alg, "EdDSA")
NEW
530
          |> put_change(:userinfo_signed_response_alg, "EdDSA")
×
531
        else
532
          {:error, error} ->
NEW
533
            add_error(changeset, :private_key, error)
×
534
        end
535

536
      private_key ->
537
        public_key = JOSE.JWK.to_public(private_key)
60✔
538

539
        {_type, public_pem} = JOSE.JWK.to_pem(public_key)
60✔
540
        {_type, private_pem} = JOSE.JWK.to_pem(private_key)
60✔
541

542
        changeset
543
        |> put_change(:public_key, public_pem)
544
        |> put_change(:private_key, private_pem)
545
        |> put_change(:signatures_adapter, Boruta.Internal.Signatures |> Atom.to_string())
60✔
546
    end
547
  end
548

549
  defp put_secret(%Ecto.Changeset{data: data, changes: changes} = changeset) do
550
    case fetch_change(changeset, :secret) do
64✔
551
      {:ok, nil} ->
552
        put_change(changeset, :secret, token_generator().secret(struct(data, changes)))
1✔
553

554
      {:ok, _secret} ->
555
        changeset
2✔
556

557
      :error ->
558
        put_change(changeset, :secret, token_generator().secret(struct(data, changes)))
61✔
559
    end
560
  end
561

562
  defp put_did(%Ecto.Changeset{} = changeset) do
563
    case get_field(changeset, :public_key) do
×
564
      nil ->
565
        changeset
×
566

567
      pem ->
568
        {_, jwk} = JOSE.JWK.from_pem(pem) |> JOSE.JWK.to_map()
×
569

570
        case Did.create("key", jwk) do
×
571
          {:ok, did, _jwk} ->
NEW
572
            "did:key:" <> key = did
×
NEW
573
            put_change(changeset, :did, "#{did}##{key}")
×
574

575
          {:error, error} ->
576
            add_error(changeset, :did, error)
×
577
        end
578
    end
579
  end
580
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc