Skip to content

Commit ce8f313

Browse files
committed
fix(phase-91): wire remote jwks operator diagnostics
1 parent 82510fe commit ce8f313

9 files changed

Lines changed: 293 additions & 32 deletions

File tree

lib/lockspire/diagnostics/remote_jwks.ex

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,24 @@ defmodule Lockspire.Diagnostics.RemoteJwks do
182182
|> Map.new()
183183
end
184184

185+
@spec snapshot(t()) :: map()
186+
def snapshot(%__MODULE__{} = incident) do
187+
%{
188+
class: incident.class,
189+
consumer: incident.consumer,
190+
stage: incident.stage,
191+
subreason: incident.subreason,
192+
fetch_status: incident.fetch_status,
193+
target_safety_reason: incident.target_safety_reason,
194+
cached_entry_present?: incident.cached_entry_present?,
195+
forced_refresh_attempted?: incident.forced_refresh_attempted?,
196+
requested_kid_present_in_cached_set?: incident.requested_kid_present_in_cached_set?,
197+
remediation: incident.remediation
198+
}
199+
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
200+
|> Map.new()
201+
end
202+
185203
@spec summarize_client(Client.t()) :: summary()
186204
def summarize_client(%Client{} = client) do
187205
if remote_jwks_client?(client) do
@@ -272,12 +290,11 @@ defmodule Lockspire.Diagnostics.RemoteJwks do
272290
"Confirm overlap-based key rollover and keep both old and new keys published until Lockspire can refresh and verify a fresh JWT."
273291
end
274292

275-
defp remote_jwks_client?(%Client{
276-
jwks_uri: jwks_uri,
277-
token_endpoint_auth_method: :private_key_jwt
278-
})
279-
when is_binary(jwks_uri) and jwks_uri != "",
280-
do: true
293+
defp remote_jwks_client?(%Client{jwks_uri: jwks_uri} = client)
294+
when is_binary(jwks_uri) and jwks_uri != "" do
295+
client.token_endpoint_auth_method == :private_key_jwt or
296+
not is_nil(client.authorization_encrypted_response_alg)
297+
end
281298

282299
defp remote_jwks_client?(_client), do: false
283300

lib/lockspire/protocol/client_auth/private_key_jwt.ex

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ defmodule Lockspire.Protocol.ClientAuth.PrivateKeyJwt do
3131
),
3232
:ok <- validate_claims(verified_assertion, verified_client, opts),
3333
:ok <- record_replay(verified_assertion, verified_client, opts) do
34+
clear_remote_jwks_diagnostic(client, opts)
3435
:ok
3536
else
3637
{:error, reason, remote_jwks_incident} ->
@@ -368,6 +369,7 @@ defmodule Lockspire.Protocol.ClientAuth.PrivateKeyJwt do
368369

369370
Observability.emit(:client_auth, action, %{}, metadata)
370371
append_audit_event(reason, client, metadata, opts)
372+
persist_remote_jwks_diagnostic(client, remote_jwks_incident, opts)
371373
end
372374

373375
defp append_audit_event(reason, %Client{} = client, metadata, opts) do
@@ -427,6 +429,41 @@ defmodule Lockspire.Protocol.ClientAuth.PrivateKeyJwt do
427429

428430
defp replay_store(opts), do: Keyword.get(opts, :jti_store, Keyword.fetch!(opts, :client_store))
429431

432+
defp persist_remote_jwks_diagnostic(%Client{} = client, %RemoteJwks{} = incident, opts) do
433+
with store when not is_nil(store) <- Keyword.get(opts, :client_store),
434+
true <- function_exported?(store, :update_client, 2) do
435+
metadata =
436+
client.metadata
437+
|> ensure_metadata()
438+
|> Map.put("remote_jwks_diagnostic", RemoteJwks.snapshot(incident))
439+
440+
_ = store.update_client(client, %{metadata: metadata})
441+
:ok
442+
else
443+
_other -> :ok
444+
end
445+
end
446+
447+
defp persist_remote_jwks_diagnostic(_client, _incident, _opts), do: :ok
448+
449+
defp clear_remote_jwks_diagnostic(%Client{jwks_uri: jwks_uri} = client, opts)
450+
when is_binary(jwks_uri) do
451+
with store when not is_nil(store) <- Keyword.get(opts, :client_store),
452+
true <- function_exported?(store, :update_client, 2),
453+
metadata when is_map(metadata) <- client.metadata,
454+
true <- Map.has_key?(metadata, "remote_jwks_diagnostic") do
455+
_ = store.update_client(client, %{metadata: Map.delete(metadata, "remote_jwks_diagnostic")})
456+
:ok
457+
else
458+
_other -> :ok
459+
end
460+
end
461+
462+
defp clear_remote_jwks_diagnostic(_client, _opts), do: :ok
463+
464+
defp ensure_metadata(metadata) when is_map(metadata), do: metadata
465+
defp ensure_metadata(_metadata), do: %{}
466+
430467
defp server_policy_store(opts),
431468
do:
432469
Keyword.get_lazy(opts, :server_policy_store, fn ->

lib/lockspire/protocol/jarm/client_key_resolver.ex

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ defmodule Lockspire.Protocol.Jarm.ClientKeyResolver do
3737
end
3838
end
3939

40-
defp do_resolve(%Client{jwks_uri: jwks_uri} = _client, params, opts) when is_binary(jwks_uri) do
40+
defp do_resolve(%Client{jwks_uri: jwks_uri} = client, params, opts) when is_binary(jwks_uri) do
4141
fetcher = Keyword.get(opts, :jwks_fetcher, Config.jwks_fetcher())
42+
opts = Keyword.put_new(opts, :client, client)
4243

4344
with {:ok, jwk_set} <- fetcher.get_keys(jwks_uri, jwks_fetcher_opts(opts)),
4445
{_modules, jwks} <- JOSE.JWK.to_map(jwk_set) do
4546
case select_key(jwks, params) do
4647
{:ok, jwk} ->
48+
clear_remote_jwks_diagnostic(client_from_opts_or_nil(opts), opts)
4749
{:ok, jwk, :jwks_uri}
4850

4951
{:error, :jarm_encryption_key_unavailable} ->
@@ -53,7 +55,9 @@ defmodule Lockspire.Protocol.Jarm.ClientKeyResolver do
5355
{:error, {:jwks_fetch_failed, _reason} = fetch_error} ->
5456
emit_remote_failure(
5557
:jarm_encryption_key_fetch_failed,
56-
RemoteJwks.classify_fetch_error(:jarm, fetch_error)
58+
RemoteJwks.classify_fetch_error(:jarm, fetch_error),
59+
client,
60+
opts
5761
)
5862

5963
{:error, :jarm_encryption_key_fetch_failed}
@@ -74,6 +78,7 @@ defmodule Lockspire.Protocol.Jarm.ClientKeyResolver do
7478

7579
case select_key(jwks, params) do
7680
{:ok, jwk} ->
81+
clear_remote_jwks_diagnostic(client_from_opts_or_nil(opts), opts)
7782
{:ok, jwk, :jwks_uri}
7883

7984
{:error, :jarm_encryption_key_unavailable} ->
@@ -86,7 +91,9 @@ defmodule Lockspire.Protocol.Jarm.ClientKeyResolver do
8691
:requested_kid_present_in_cached_set?,
8792
requested_kid_present?(jwks, params)
8893
)
89-
)
94+
),
95+
client_from_opts_or_nil(opts),
96+
opts
9097
)
9198

9299
{:error, :jarm_encryption_key_unavailable}
@@ -95,7 +102,9 @@ defmodule Lockspire.Protocol.Jarm.ClientKeyResolver do
95102
{:error, {:jwks_fetch_failed, _reason} = fetch_error} ->
96103
emit_remote_failure(
97104
:jarm_encryption_key_fetch_failed,
98-
RemoteJwks.classify_fetch_error(:jarm, fetch_error, remote_opts)
105+
RemoteJwks.classify_fetch_error(:jarm, fetch_error, remote_opts),
106+
client_from_opts_or_nil(opts),
107+
opts
99108
)
100109

101110
{:error, :jarm_encryption_key_fetch_failed}
@@ -180,6 +189,47 @@ defmodule Lockspire.Protocol.Jarm.ClientKeyResolver do
180189
Observability.emit(:jarm, :failed, %{}, metadata)
181190
end
182191

192+
defp emit_remote_failure(reason_code, remote_jwks_incident, %Client{} = client, opts) do
193+
emit_remote_failure(reason_code, remote_jwks_incident)
194+
persist_remote_jwks_diagnostic(client, remote_jwks_incident, opts)
195+
end
196+
197+
defp client_from_opts_or_nil(opts), do: Keyword.get(opts, :client)
198+
199+
defp persist_remote_jwks_diagnostic(%Client{} = client, %RemoteJwks{} = incident, opts) do
200+
with store when not is_nil(store) <- Keyword.get(opts, :client_store, Config.repo!()),
201+
true <- function_exported?(store, :update_client, 2) do
202+
metadata =
203+
client.metadata
204+
|> ensure_metadata()
205+
|> Map.put("remote_jwks_diagnostic", RemoteJwks.snapshot(incident))
206+
207+
_ = store.update_client(client, %{metadata: metadata})
208+
:ok
209+
else
210+
_other -> :ok
211+
end
212+
end
213+
214+
defp persist_remote_jwks_diagnostic(_client, _incident, _opts), do: :ok
215+
216+
defp clear_remote_jwks_diagnostic(%Client{} = client, opts) do
217+
with store when not is_nil(store) <- Keyword.get(opts, :client_store, Config.repo!()),
218+
true <- function_exported?(store, :update_client, 2),
219+
metadata when is_map(metadata) <- client.metadata,
220+
true <- Map.has_key?(metadata, "remote_jwks_diagnostic") do
221+
_ = store.update_client(client, %{metadata: Map.delete(metadata, "remote_jwks_diagnostic")})
222+
:ok
223+
else
224+
_other -> :ok
225+
end
226+
end
227+
228+
defp clear_remote_jwks_diagnostic(_client, _opts), do: :ok
229+
230+
defp ensure_metadata(metadata) when is_map(metadata), do: metadata
231+
defp ensure_metadata(_metadata), do: %{}
232+
183233
defp jwks_fetcher_opts(opts) do
184234
Config.jwks_fetcher_opts()
185235
|> Keyword.merge(Keyword.get(opts, :jwks_fetcher_opts, []))

lib/mix/tasks/lockspire.doctor.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule Mix.Tasks.Lockspire.Doctor do
2+
@moduledoc """
3+
Dispatcher for Lockspire runtime diagnostic subcommands.
4+
"""
5+
6+
use Mix.Task
7+
8+
@shortdoc "Runs Lockspire runtime diagnostic commands"
9+
10+
@impl Mix.Task
11+
def run(["remote-jwks" | rest]) do
12+
Mix.Task.run("lockspire.doctor.remote_jwks", rest)
13+
end
14+
15+
def run(_args) do
16+
Mix.raise("""
17+
Unknown doctor command.
18+
19+
Supported commands:
20+
mix lockspire.doctor remote-jwks --client CLIENT_ID
21+
""")
22+
end
23+
end

test/lockspire/admin/clients_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,35 @@ defmodule Lockspire.Admin.ClientsTest do
253253
"mix lockspire.doctor remote-jwks --client admin-remote-jwks-summary"
254254
end
255255

256+
test "remote_jwks_summary/1 also applies to JARM-only jwks_uri clients" do
257+
{:ok, client} =
258+
Repository.register_client(%Client{
259+
client_id: "admin-remote-jwks-jarm",
260+
client_secret_hash: "sha256:remote:jarm",
261+
client_type: :confidential,
262+
name: "Admin Remote JWKS JARM",
263+
redirect_uris: ["https://remote.example.com/callback"],
264+
allowed_scopes: ["openid"],
265+
allowed_grant_types: ["authorization_code"],
266+
allowed_response_types: ["code"],
267+
token_endpoint_auth_method: :client_secret_basic,
268+
authorization_encrypted_response_alg: :RSA_OAEP_256,
269+
authorization_encrypted_response_enc: :A256GCM,
270+
pkce_required: true,
271+
subject_type: :public,
272+
created_at: DateTime.utc_now(),
273+
jwks_uri: "https://remote.example.com/.well-known/jwks.json",
274+
metadata: %{}
275+
})
276+
277+
summary = Clients.remote_jwks_summary(client)
278+
279+
assert summary.applicable? == true
280+
assert summary.status == :supported
281+
assert summary.command_hint =~
282+
"mix lockspire.doctor remote-jwks --client admin-remote-jwks-jarm"
283+
end
284+
256285
test "remote_jwks_summary/1 reuses the shared incident taxonomy from client metadata" do
257286
{:ok, client} =
258287
Repository.register_client(%Client{

test/lockspire/protocol/client_auth_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ defmodule Lockspire.Protocol.ClientAuthTest do
2020
Process.delete(:recorded_audit_events)
2121
Process.delete(:force_replay_store_error)
2222
Process.delete(:client_secret_jwt_secret)
23+
Process.delete(:updated_remote_client)
2324
:ok
2425
end
2526

@@ -91,6 +92,7 @@ defmodule Lockspire.Protocol.ClientAuthTest do
9192
)
9293

9394
assert Process.get(:fetched_jwks_uri) == "https://keys.example.test/client.jwks.json"
95+
assert Process.get(:updated_remote_client) == nil
9496
end
9597

9698
test "refreshes remote jwks once on key mismatch and retries verification" do
@@ -123,6 +125,7 @@ defmodule Lockspire.Protocol.ClientAuthTest do
123125

124126
assert Process.get(:fetched_jwks_uri) == "https://keys.example.test/client.jwks.json"
125127
assert Process.get(:refreshed_jwks_uri) == "https://keys.example.test/client.jwks.json"
128+
assert Process.get(:updated_remote_client) == nil
126129
end
127130

128131
test "rejects algorithms outside the effective issuer allowlist" do
@@ -345,6 +348,10 @@ defmodule Lockspire.Protocol.ClientAuthTest do
345348
remote_jwks_forced_refresh_attempted?: true,
346349
remote_jwks_requested_kid_present_in_cached_set?: false
347350
}}
351+
352+
assert %{"remote_jwks_diagnostic" => diagnostic} = Process.get(:updated_remote_client)
353+
assert diagnostic[:class] == :remote_jwks_key_unavailable
354+
assert diagnostic[:consumer] == :private_key_jwt
348355
end
349356

350357
test "emits shared remote jwks incident metadata for guarded fetch failures" do
@@ -388,6 +395,10 @@ defmodule Lockspire.Protocol.ClientAuthTest do
388395
remote_jwks_fetch_status: 503,
389396
remote_jwks_forced_refresh_attempted?: false
390397
}}
398+
399+
assert %{"remote_jwks_diagnostic" => diagnostic} = Process.get(:updated_remote_client)
400+
assert diagnostic[:class] == :remote_jwks_fetch_failed
401+
assert diagnostic[:fetch_status] == 503
391402
end
392403

393404
test "emits shared remote jwks signature-invalid metadata when the refreshed kid exists" do
@@ -439,6 +450,10 @@ defmodule Lockspire.Protocol.ClientAuthTest do
439450
remote_jwks_forced_refresh_attempted?: true,
440451
remote_jwks_requested_kid_present_in_cached_set?: true
441452
}}
453+
454+
assert %{"remote_jwks_diagnostic" => diagnostic} = Process.get(:updated_remote_client)
455+
assert diagnostic[:class] == :remote_jwks_signature_invalid
456+
assert diagnostic[:consumer] == :private_key_jwt
442457
end
443458
end
444459

@@ -750,6 +765,11 @@ defmodule Lockspire.Protocol.ClientAuthTest do
750765
end
751766

752767
def fetch_client_by_id(_), do: {:ok, nil}
768+
769+
def update_client(_client, attrs) do
770+
Process.put(:updated_remote_client, attrs.metadata)
771+
{:ok, struct(Client, attrs)}
772+
end
753773
end
754774

755775
defmodule RemoteJwksFetcher do

0 commit comments

Comments
 (0)