| phase | 26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| plan | 5 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type | execute | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| wave | 2 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| depends_on |
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| files_modified |
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| autonomous | true | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| requirements |
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| must_haves |
|
Purpose: This is the heart of Phase 26 — DCR-02 (intake validation), DCR-03 (PKCE floor), DCR-04 (credential issuance + hash-at-rest) all close here. The orchestrator wires together every Wave 0 and Wave 1 primitive: the public generate_client_id/0, the tightened actor_from_attrs/1, the new DCR-aware create_dcr_client/1 (which preserves provenance/RAT-hash/IAT-FK/issued_at/expires_at end-to-end, unlike Lockspire.Clients.register_client/1 which strips DCR fields), the RAT primitives, the IAT atomic redeemer, and the DCR fixtures (in tests). Every D-14 / D-15 axis must produce the exact %Error{field, reason} shape so the Phase 27 HTTP adapter can map to RFC 7591 §3.2.2 error responses.
This plan splits the implementation across two TDD tasks: Task 2a builds the module shell + substructs + register/1 skeleton + the precondition gate + validate_intake_metadata/2. Task 2b adds credential generation + persistence (with the full RFC 7591 → %Domain.Client{} field mapping table) + telemetry. RED→GREEN per task.
Output: A 200-300-line orchestrator module + a comprehensive test suite covering happy path, every sad path, persistence assertions (the row has pkce_required: true, hashed credentials, correct provenance, RAT hash matches Policy.hash_token(plaintext), Policy.verify_client_secret(stored_hash, returned_plaintext) == true, IAT FK populated, software_statement passthrough silently ignored), audit-row attribution, and telemetry-event assertions.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/STATE.md @.planning/ROADMAP.md @.planning/REQUIREMENTS.md @.planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-CONTEXT.md @.planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-PATTERNS.md @.planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-VALIDATION.md @lib/lockspire/protocol/pushed_authorization_request.ex @lib/lockspire/protocol/dcr_policy.ex @lib/lockspire/clients.ex @lib/lockspire/admin/clients.ex @lib/lockspire/security/policy.ex @lib/lockspire/observability.ex @lib/lockspire/storage/ecto/repository.ex @test/support/fixtures/dcr_fixtures.exFrom lib/lockspire/protocol/pushed_authorization_request.ex (lines 13-90, 177-179):
SuccessandErrorsubstructs (template for Phase 26 substructs).withpipeline shape insidepush/1.wrap_jar_error/1collapse pattern.
From lib/lockspire/protocol/initial_access_token.ex (Wave 1 plan 26-03):
@spec redeem(String.t()) :: {:ok, Lockspire.Domain.InitialAccessToken.t()} | {:error, :invalid_token}From lib/lockspire/protocol/dcr_policy.ex (Phase 25):
@spec resolve(ServerPolicy.t(), iat_overrides_or_nil :: map() | nil, inbound_metadata :: map()) ::
{:ok, Resolved.t()} | {:error, :invalid_client_metadata, %{field: atom(), reason: atom(), allowed: list() | nil}}The Resolved struct (also Phase 25) carries the narrowed allowlists Phase 26 reads back from. Read lib/lockspire/protocol/dcr_policy.ex to confirm the exact field names.
From lib/lockspire/clients.ex:
@spec validate_redirect_uris(list()) :: :ok | {:error, term()} # line 32
def validate_redirect_uris(redirect_uris)
@spec generate_client_id() :: String.t() # line 384 (PUBLIC after Wave 0 plan 26-01)
def generate_client_id
@spec rotate_secret_hash() :: {hash :: String.t(), plaintext :: String.t()} # lines 53-56
def rotate_secret_hashFrom lib/lockspire/protocol/registration_access_token.ex (Wave 1 plan 26-02):
@spec generate() :: {plaintext :: String.t(), hash :: String.t()}
def generateFrom lib/lockspire/admin/clients.ex:
# Public; the persistence path goes through this. After Wave 0 plan 26-01 it raises ArgumentError on missing actor.type.
def create_client(attrs)Read lib/lockspire/admin/clients.ex lines 50-130 to confirm the attrs shape create_client/1 accepts. The orchestrator builds attrs = %{client: %Domain.Client{...}, actor: %{type: :dcr, id: ..., display: ...}} (or whatever the existing shape demands — read first, don't guess).
From lib/lockspire/observability.ex:
def emit(event_name, measurements \\ %{}, metadata \\ %{}) when is_atom(event_name)Phase 26 event names (D-26): :dcr_registration_succeeded, :dcr_registration_rejected.
From test/support/fixtures/dcr_fixtures.ex (Wave 1 plan 26-04):
valid_metadata/0,invalid_jwks_uri_metadata/0,mutual_jwks_metadata/0,incoherent_grant_response_metadata/0,invalid_redirect_uri_metadata/0,pkce_required_false_metadata/0,server_policy/1,register_request/1.
Public surface this plan delivers:
defmodule Lockspire.Protocol.Registration do
defmodule Success do
@type t :: %__MODULE__{
client: Lockspire.Domain.Client.t(),
client_secret_plaintext: String.t() | nil,
registration_access_token_plaintext: String.t()
}
defstruct [:client, :client_secret_plaintext, :registration_access_token_plaintext]
end
defmodule Error do
@type t :: %__MODULE__{
code: atom(),
field: atom() | nil,
reason: atom() | nil,
allowed: list() | nil
}
defstruct [:code, :field, :reason, :allowed]
end
@type result :: {:ok, Success.t()} | {:error, Error.t()}
@spec register(map()) :: result()
def register(request) when is_map(request)
# @doc false but exported (def, not defp) so Lockspire.Protocol.RegistrationManagement
# (Wave 2 plan 26-06) can call the same validator pipeline per D-02.
@doc false
@spec validate_intake_metadata(map(), Resolved.t()) :: :ok | {:error, Error.t()}
def validate_intake_metadata(metadata, resolved)
end`describe "register/1 happy path"`:
- test "returns `{:ok, %Success{client: %Client{}, client_secret_plaintext: bin, registration_access_token_plaintext: bin}}` for valid metadata + redeemable IAT"
- test "persisted `Domain.Client` has `pkce_required: true` regardless of inbound `pkce_required`" (omit inbound `pkce_required` and verify the persisted row's value)
- test "persisted `Domain.Client` has `client_secret_hash` matching the format `\"sha256:<salt>:<hash>\"`"
- test "**round-trip proof: `Lockspire.Security.Policy.verify_client_secret(persisted.client_secret_hash, success.client_secret_plaintext)` returns `true`** — the persisted hash and the returned plaintext form a matched pair. If this fails, the persistence path silently regenerated the secret server-side and the plaintext returned to the caller is unusable (warning 8)."
- test "persisted `Domain.Client` has `registration_access_token_hash` equal to `Policy.hash_token(success.registration_access_token_plaintext)`"
- test "persisted `Domain.Client` has `provenance: :self_registered`"
- test "persisted `Domain.Client` has `initial_access_token_id` matching the redeemed IAT's id when iat is non-nil"
- test "persisted `Domain.Client` has `client_id_issued_at` set to a recent UTC datetime"
- test "persisted `Domain.Client` has `client_secret_expires_at` set from `Resolved.dcr_default_client_secret_lifetime_seconds`"
- test "**unknown-field passthrough: `register/1` silently ignores `software_statement`** — given `metadata = Map.put(valid_metadata, \"software_statement\", \"eyJ...\")`, registration succeeds normally, the persisted row has no `software_statement` field set, the persisted `metadata` JSONB column does NOT contain the key `\"software_statement\"` (warning 7; RESEARCH Q6 RESOLVED)."
- test "happy-path emits `:dcr_registration_succeeded` event with `actor_type: :dcr` in metadata and NO plaintext fields"
- test "audit row written by happy path has `actor_type == \"dcr\"` (NOT `\"operator\"`)"
`describe "register/1 — IAT precondition gate (D-13 step 1 — RESEARCH Q5 RESOLVED)"`:
- test "rejects with `{:error, %Error{code: :invalid_token, field: :iat, reason: :missing}}` when `server_policy.registration_policy == :initial_access_token` AND `iat == nil` — fired BEFORE `maybe_redeem_iat/1` runs"
- test "the precondition emits `:dcr_registration_rejected` telemetry with `code: :invalid_token, field: :iat, reason: :missing` and NO call to `InitialAccessToken.redeem/1` (verify by attaching a `:telemetry` handler to `[:lockspire, :iat_redemption_failed]` and asserting `refute_received {:telemetry_event, [:lockspire, :iat_redemption_failed], _, _}, 200`)"
- test "succeeds when `server_policy.registration_policy == :open` AND `iat == nil` (the gate does NOT trigger)"
- test "succeeds when `server_policy.registration_policy == :initial_access_token` AND `iat` is a valid plaintext (the gate does NOT trigger; `maybe_redeem_iat/1` runs and IAT is consumed)"
`describe "register/1 — IAT redemption (D-13 step 2)"`:
- test "rejects with `{:error, :invalid_token}` when iat is non-nil but already used"
- test "rejects with `{:error, :invalid_token}` when iat is non-nil but revoked"
- test "rejects with `{:error, :invalid_token}` when iat is non-nil but expired"
- test "registers anonymously when iat is nil and server_policy.registration_policy == :open" (audit actor.id == \"anonymous\")
`describe "register/1 — D-14 validator"`:
- test "rejects metadata with `jwks_uri` returning `%Error{code: :invalid_client_metadata, field: :jwks_uri, reason: :unsupported_in_slice}`"
- test "rejects metadata with both `jwks` and `jwks_uri` returning `%Error{field: :jwks, reason: :mutually_exclusive_with_jwks_uri}`" (note: `jwks_uri` is rejected first, but the test uses fixture `mutual_jwks_metadata` that may trigger either; pin to whichever fixture-rule the validator hits first)
- test "rejects RFC 7591 §2 incoherent grant/response pair (`refresh_token` without `authorization_code`) returning `%Error{field: :grant_types, reason: :incoherent_pair}` (or `field: :response_types`)"
- test "rejects redirect URIs that fail `Lockspire.Clients.validate_redirect_uris/1` returning `%Error{field: :redirect_uris}`"
`describe "register/1 — D-15 PKCE floor"`:
- test "rejects metadata with explicit `pkce_required: false` returning `%Error{field: :pkce_required, reason: :pkce_floor_required_for_dcr}`"
- test "accepts metadata that omits `pkce_required` (the persisted row gets `pkce_required: true`)"
`describe "register/1 — failure-path telemetry"`:
- test "every sad path emits `:dcr_registration_rejected` (or per-axis variant) with `reason` measurement and NO plaintext"
- test "no audit row from sad path is attributed to `:operator` (regression sentinel — full sweep lives in plan 26-07's `dcr_audit_attribution_test.exs`)"
Use `:telemetry.attach_many/4` to capture both `[:lockspire, :dcr_registration_succeeded]` and `[:lockspire, :audit, :dcr_registration_succeeded]` (and the `_rejected` variants). Detach in `on_exit`.
Run `mix test test/lockspire/protocol/registration_test.exs` — every test must FAIL with `(UndefinedFunctionError) function Lockspire.Protocol.Registration.register/1 is undefined` (RED — module doesn't exist yet).
Module preamble:
```elixir
defmodule Lockspire.Protocol.RegistrationTest do
use ExUnit.Case, async: false
import Ecto.Query
alias Lockspire.Domain.Client
alias Lockspire.Domain.InitialAccessToken, as: IatDomain
alias Lockspire.Protocol.Registration
alias Lockspire.Protocol.Registration.Error
alias Lockspire.Protocol.Registration.Success
alias Lockspire.Security.Policy
alias Lockspire.Storage.Ecto.AuditEventRecord
alias Lockspire.Storage.Ecto.Repository
alias Lockspire.Test.Fixtures.DcrFixtures
alias Lockspire.Test.Fixtures.InitialAccessTokenFixtures
setup_all do
Application.put_env(:lockspire, :repo, Lockspire.TestRepo)
start_supervised!(Lockspire.TestRepo)
Ecto.Adapters.SQL.Sandbox.mode(Lockspire.TestRepo, :manual)
:ok
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Lockspire.TestRepo)
handler_id = "registration-test-#{System.unique_integer([:positive])}"
:ok =
:telemetry.attach_many(
handler_id,
[
[:lockspire, :dcr_registration_succeeded],
[:lockspire, :dcr_registration_rejected],
[:lockspire, :audit, :dcr_registration_succeeded],
[:lockspire, :audit, :dcr_registration_rejected]
],
fn event, measurements, metadata, pid ->
send(pid, {:telemetry_event, event, measurements, metadata})
end,
self()
)
on_exit(fn -> :telemetry.detach(handler_id) end)
%{}
end
```
Implement every `describe` block above with concrete `assert ... = Registration.register(DcrFixtures.register_request(...))` patterns. Use `assert_receive {:telemetry_event, ...}, 500` for telemetry assertions. For the audit-row check, query directly:
```elixir
rows = Lockspire.TestRepo.all(
from(audit in AuditEventRecord,
where: like(audit.action, "dcr_%"),
order_by: [desc: audit.id])
)
refute Enum.any?(rows, &(&1.actor_type == "operator"))
```
Run `mix test test/lockspire/protocol/registration_test.exs` and confirm all tests fail (RED).
Step 1 — Module shell + substructs (D-12):
```elixir
defmodule Lockspire.Protocol.Registration do
@moduledoc """
RFC 7591 dynamic client registration intake — `Plug.Conn`-free orchestrator.
Pipeline (per Phase 26 D-13, refined per RESEARCH Q5 RESOLVED):
1. Precondition gate — when `server_policy.registration_policy == :initial_access_token`
and `iat == nil`, reject with `%Error{code: :invalid_token, field: :iat, reason: :missing}`
BEFORE any other step.
2. IAT redemption via `Lockspire.Protocol.InitialAccessToken.redeem/1` (skipped if `iat` is nil).
3. DcrPolicy resolution via `Lockspire.Protocol.DcrPolicy.resolve/3` (Phase 25).
4. Slice-specific intake validation (D-14 jwks/coherence/redirect + D-15 PKCE floor).
5. Credential generation (`client_id`, `client_secret`, `registration_access_token`).
6. Persistence via `Lockspire.Admin.Clients.create_dcr_client/1` (DCR-aware persistence
helper from plan 26-01 task 4 — preserves provenance/RAT-hash/IAT-FK/issued_at/expires_at
verbatim, unlike the legacy `Lockspire.Clients.register_client/1` which strips them).
7. Post-commit audit + telemetry emission (`:dcr_registration_succeeded` /
`:dcr_registration_rejected`).
Per D-11 IAT-style enumeration defense, IAT redemption failures collapse to
`%Error{code: :invalid_token}` (the discriminator stays in telemetry).
"""
alias Lockspire.Admin
alias Lockspire.Clients
alias Lockspire.Domain.Client
alias Lockspire.Domain.InitialAccessToken, as: IatDomain
alias Lockspire.Domain.ServerPolicy
alias Lockspire.Observability
alias Lockspire.Protocol.DcrPolicy
alias Lockspire.Protocol.DcrPolicy.Resolved
alias Lockspire.Protocol.InitialAccessToken
alias Lockspire.Protocol.RegistrationAccessToken
alias Lockspire.Security.Policy
defmodule Success do
@type t :: %__MODULE__{
client: Client.t(),
client_secret_plaintext: String.t() | nil,
registration_access_token_plaintext: String.t()
}
defstruct [:client, :client_secret_plaintext, :registration_access_token_plaintext]
end
defmodule Error do
@type t :: %__MODULE__{
code: atom(),
field: atom() | nil,
reason: atom() | nil,
allowed: list() | nil
}
defstruct [:code, :field, :reason, :allowed]
end
@type result :: {:ok, Success.t()} | {:error, Error.t()}
```
Step 2 — Public `register/1` with precondition gate + `with` pipeline:
```elixir
@spec register(map()) :: result()
def register(%{metadata: metadata, server_policy: %ServerPolicy{} = server_policy} = request)
when is_map(metadata) do
iat = Map.get(request, :iat)
source = Map.get(request, :source, %{ip: nil, user_agent: nil})
with :ok <- require_iat_when_policy_demands(server_policy, iat),
{:ok, iat_record} <- maybe_redeem_iat(iat),
{:ok, %Resolved{} = resolved} <- resolve_policy(server_policy, iat_record, metadata),
:ok <- validate_intake_metadata(metadata, resolved),
credentials <- generate_credentials(),
{:ok, %Client{} = client} <- persist_client(metadata, resolved, iat_record, credentials, source) do
emit_succeeded(client, iat_record, source)
{:ok,
%Success{
client: client,
client_secret_plaintext: credentials.client_secret,
registration_access_token_plaintext: credentials.rat
}}
else
{:error, %Error{} = error} ->
emit_rejected(error, source)
{:error, error}
end
end
```
Step 3 — Precondition gate (RESEARCH Q5 RESOLVED — the new step):
```elixir
# Reject anonymous registration when server policy demands an IAT.
# Fired BEFORE maybe_redeem_iat/1 so no IAT-redemption-failure telemetry is emitted on this axis.
defp require_iat_when_policy_demands(%ServerPolicy{registration_policy: :initial_access_token}, nil) do
{:error, %Error{code: :invalid_token, field: :iat, reason: :missing}}
end
defp require_iat_when_policy_demands(%ServerPolicy{}, _iat), do: :ok
```
Step 4 — `maybe_redeem_iat/1` (private; collapses IAT failures to `%Error{code: :invalid_token}` per D-11):
```elixir
defp maybe_redeem_iat(nil), do: {:ok, nil}
defp maybe_redeem_iat(plaintext) when is_binary(plaintext) do
case InitialAccessToken.redeem(plaintext) do
{:ok, %IatDomain{} = iat} -> {:ok, iat}
{:error, :invalid_token} -> {:error, %Error{code: :invalid_token}}
end
end
```
Step 5 — `resolve_policy/3` (private wrapper around DcrPolicy.resolve/3):
```elixir
defp resolve_policy(server_policy, iat_record, metadata) do
iat_overrides = iat_record && Map.get(iat_record, :policy_overrides)
case DcrPolicy.resolve(server_policy, iat_overrides, metadata) do
{:ok, %Resolved{} = resolved} ->
{:ok, resolved}
{:error, :invalid_client_metadata, %{field: field, reason: reason} = info} ->
{:error,
%Error{
code: :invalid_client_metadata,
field: field,
reason: reason,
allowed: Map.get(info, :allowed)
}}
end
end
```
Step 6 — `validate_intake_metadata/2` (PUBLIC `@doc false`, so `RegistrationManagement.update/2` in plan 26-06 can reuse it per D-02; covers D-14 + D-15):
```elixir
@doc false
@spec validate_intake_metadata(map(), Resolved.t()) :: :ok | {:error, Error.t()}
def validate_intake_metadata(metadata, %Resolved{} = _resolved) when is_map(metadata) do
with :ok <- validate_jwks(metadata),
:ok <- validate_grant_response_coherence(metadata),
:ok <- validate_redirect_uris(metadata),
:ok <- validate_pkce_floor(metadata) do
:ok
end
end
# D-14: jwks_uri rejected first (mutual-exclusion check is shadowed when both present
# because jwks_uri rule fires first; we still keep the explicit rule for spec clarity).
defp validate_jwks(metadata) do
cond do
Map.has_key?(metadata, "jwks_uri") ->
{:error, %Error{code: :invalid_client_metadata, field: :jwks_uri, reason: :unsupported_in_slice}}
Map.has_key?(metadata, "jwks") and Map.has_key?(metadata, "jwks_uri") ->
{:error, %Error{code: :invalid_client_metadata, field: :jwks, reason: :mutually_exclusive_with_jwks_uri}}
true ->
:ok
end
end
# D-14: RFC 7591 §2 grant_types/response_types coherence.
defp validate_grant_response_coherence(metadata) do
grant_types = Map.get(metadata, "grant_types", []) |> List.wrap()
response_types = Map.get(metadata, "response_types", []) |> List.wrap()
cond do
"refresh_token" in grant_types and "authorization_code" not in grant_types ->
{:error, %Error{code: :invalid_client_metadata, field: :grant_types, reason: :incoherent_pair}}
"code" in response_types and "authorization_code" not in grant_types ->
{:error, %Error{code: :invalid_client_metadata, field: :response_types, reason: :incoherent_pair}}
true ->
:ok
end
end
defp validate_redirect_uris(metadata) do
redirect_uris = Map.get(metadata, "redirect_uris", [])
case Clients.validate_redirect_uris(redirect_uris) do
:ok ->
:ok
{:error, _reason} ->
{:error, %Error{code: :invalid_client_metadata, field: :redirect_uris, reason: :invalid_uri}}
end
end
# D-15: explicit `pkce_required: false` is rejected (not silently coerced).
defp validate_pkce_floor(metadata) do
case Map.get(metadata, "pkce_required") do
false -> {:error, %Error{code: :invalid_client_metadata, field: :pkce_required, reason: :pkce_floor_required_for_dcr}}
_ -> :ok
end
end
```
Step 7 — Stub `generate_credentials/0` and `persist_client/5` (Task 2b implements these):
```elixir
defp generate_credentials do
# Task 2b: full implementation
raise "generate_credentials/0 stub — Task 2b implements"
end
defp persist_client(_metadata, _resolved, _iat_record, _credentials, _source) do
# Task 2b: full implementation
{:error, %Error{code: :persistence_error, reason: :not_implemented}}
end
```
Step 8 — Stub `emit_succeeded/3` and `emit_rejected/2`:
```elixir
defp emit_succeeded(_client, _iat_record, _source), do: :ok
defp emit_rejected(_error, _source), do: :ok
```
Step 9 — Run tests:
`mix test test/lockspire/protocol/registration_test.exs --include "describe 'register/1 — D-14 validator'" --max-failures=1` — passes.
`mix test test/lockspire/protocol/registration_test.exs --include "describe 'register/1 — D-15 PKCE floor'" --max-failures=1` — passes.
`mix test test/lockspire/protocol/registration_test.exs --include "describe 'register/1 — IAT precondition gate (D-13 step 1 — RESEARCH Q5 RESOLVED)'" --max-failures=1` — passes (the gate fires before maybe_redeem_iat/1).
`mix test test/lockspire/protocol/registration_test.exs --include "describe 'register/1 — IAT redemption (D-13 step 2)'" --max-failures=1` — passes for the rejection axes; the "registers anonymously" test fails (waiting on Task 2b).
Run `mix qa` — exit 0 (no warnings).
Step 1 — Replace the Task-2a stubs with the real `generate_credentials/0`:
```elixir
defp generate_credentials do
{client_secret_hash, client_secret} = Clients.rotate_secret_hash()
{rat_plaintext, rat_hash} = RegistrationAccessToken.generate()
client_id = Clients.generate_client_id()
%{
client_id: client_id,
client_secret: client_secret,
client_secret_hash: client_secret_hash,
rat: rat_plaintext,
rat_hash: rat_hash
}
end
```
Step 2 — Replace the Task-2a stub with the FULLY-SPECIFIED `persist_client/5`. The key insight: build a complete `%Domain.Client{}` struct (every field populated per the mapping table below), then hand it to `Admin.Clients.create_dcr_client/1` (the new helper from plan 26-01 task 4) which persists it verbatim through `Repository.register_client/1` + `transact_with_audit/2`. NO call to `Lockspire.Clients.register_client/1` (that path strips DCR fields).
**RFC 7591 wire field → `%Domain.Client{}` field mapping table (PIN — this is the contract):**
| RFC 7591 wire | Domain.Client field | Source | Normalization |
|---------------|---------------------|--------|---------------|
| `redirect_uris` | `:redirect_uris` | `metadata["redirect_uris"]` | exact-match list, validated upstream by `Lockspire.Clients.validate_redirect_uris/1` |
| `grant_types` | `:allowed_grant_types` | `metadata["grant_types"]` (default `["authorization_code"]`) | normalized list of strings, intersected with `resolved.dcr_allowed_grant_types` |
| `response_types` | `:allowed_response_types` | `metadata["response_types"]` (default `["code"]`) | normalized list of strings, intersected with `resolved.dcr_allowed_response_types` |
| `token_endpoint_auth_method` | `:token_endpoint_auth_method` | `metadata["token_endpoint_auth_method"]` (default `"client_secret_basic"`) | atomized; one of `:client_secret_basic | :client_secret_post | :private_key_jwt | :none` |
| `client_name` | `:name` | `metadata["client_name"]` | as-is string (or nil) |
| `scope` | `:allowed_scopes` | `metadata["scope"]` | space-separated string → list of scope strings, validated against `resolved.dcr_allowed_scopes` |
| `client_uri` | `:metadata["client_uri"]` | `metadata["client_uri"]` | passed through under `:metadata` JSONB column (not a top-level Domain.Client field in v1.5) |
| `logo_uri` | `:logo_uri` | `metadata["logo_uri"]` | as-is string (or nil) |
| `tos_uri` | `:tos_uri` | `metadata["tos_uri"]` | as-is string (or nil) |
| `policy_uri` | `:policy_uri` | `metadata["policy_uri"]` | as-is string (or nil) |
| `contacts` | `:contacts` | `metadata["contacts"]` | normalized list of strings (or `[]`) |
| (n/a wire) | `:client_id` | `credentials.client_id` (`Clients.generate_client_id/0`) | always generated server-side; metadata's `client_id` rejected by Phase 25 `DcrPolicy.resolve/3` |
| (n/a wire) | `:client_secret_hash` | `credentials.client_secret_hash` (`Policy.hash_client_secret(plaintext)` via `Clients.rotate_secret_hash/0`) | hash-at-rest format `"sha256:<salt>:<hash>"`; plaintext returned ONCE on `Success` |
| (n/a wire) | `:registration_access_token_hash` | `credentials.rat_hash` (`Policy.hash_token(rat_plaintext)` via `RegistrationAccessToken.generate/0`) | hash-at-rest plain SHA-256 lowercase hex; plaintext returned ONCE on `Success` |
| (n/a wire) | `:client_type` | derived from `:token_endpoint_auth_method` (`"none"` → `:public`; everything else → `:confidential`) | enforces RFC 7591 §2 client-type/auth-method coherence on the persisted row |
| (n/a wire) | `:pkce_required` | `true` (HARDCODED per D-15) | NEVER lowered; inbound `pkce_required: false` is rejected upstream by `validate_pkce_floor/1`; this literal floors the persisted row regardless |
| (n/a wire) | `:subject_type` | `:public` (default) | v1.5 doesn't expose pairwise; future phase |
| (n/a wire) | `:provenance` | `:self_registered` (HARDCODED) | NEVER `:operator` for this codepath; D-23 |
| (n/a wire) | `:initial_access_token_id` | `iat_record && iat_record.id` (or `nil` for anonymous mode) | references the IAT row marked used in the same transaction; FK to `lockspire_initial_access_tokens.id` |
| (n/a wire) | `:client_id_issued_at` | `now = DateTime.utc_now() |> DateTime.truncate(:microsecond)` | RFC 7591 §3.2.1; persisted on the row, returned in the JSON response by Phase 27 |
| (n/a wire) | `:client_secret_expires_at` | `DateTime.add(now, resolved.dcr_default_client_secret_lifetime_seconds || 0, :second)` | from server-policy/iat-overrides intersection (Phase 25 `Resolved`) |
| (n/a wire) | `:active` | `true` (default — newly created clients are active) | `disable_client/2` flips this on RFC 7592 delete |
| (n/a wire) | `:metadata` | `Map.take(metadata, ["client_uri"])` (and any other RFC 7591 wire-format extension fields we explicitly support; for v1.5 only `:client_uri` lands here) | JSONB column; do NOT include `software_statement` or any other unknown RFC 7591 extension field (RESEARCH Q6 RESOLVED — silently ignored) |
| `software_statement` | (DROPPED) | — | RFC 7591 §2.3 silently ignored (RESEARCH Q6 RESOLVED); the validator does NOT reject; persistence does NOT include the field. The persisted row's `:metadata` JSONB column does NOT contain the key `"software_statement"` (warning 7). |
| `jwks_uri` | (REJECTED at validator) | — | D-14: `validate_jwks/1` returns `{:error, %Error{field: :jwks_uri}}` before reaching `persist_client/5` |
| `jwks` (alone) | `:jwks` | `metadata["jwks"]` | as-is map (rare in v1.5; Phase 25 schema accepts the field) |
Concrete code:
```elixir
defp persist_client(metadata, %Resolved{} = resolved, iat_record, credentials, source) do
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
iat_id = iat_record && Map.get(iat_record, :id)
auth_method = atomize_auth_method(Map.get(metadata, "token_endpoint_auth_method", "client_secret_basic"))
client_type = client_type_from_auth_method(auth_method)
client = %Client{
client_id: credentials.client_id,
client_secret_hash: credentials.client_secret_hash,
client_type: client_type,
name: Map.get(metadata, "client_name"),
redirect_uris: Map.get(metadata, "redirect_uris", []),
allowed_scopes: parse_scope(Map.get(metadata, "scope", "")),
allowed_grant_types: Map.get(metadata, "grant_types", ["authorization_code"]),
allowed_response_types: Map.get(metadata, "response_types", ["code"]),
token_endpoint_auth_method: auth_method,
pkce_required: true,
subject_type: :public,
logo_uri: Map.get(metadata, "logo_uri"),
tos_uri: Map.get(metadata, "tos_uri"),
policy_uri: Map.get(metadata, "policy_uri"),
contacts: Map.get(metadata, "contacts", []),
jwks: Map.get(metadata, "jwks"),
active: true,
provenance: :self_registered,
registration_access_token_hash: credentials.rat_hash,
initial_access_token_id: iat_id,
client_id_issued_at: now,
client_secret_expires_at:
DateTime.add(now, resolved.dcr_default_client_secret_lifetime_seconds || 0, :second),
metadata: build_extension_metadata(metadata)
}
attrs = %{
client: client,
actor: %{
type: :dcr,
id: iat_id_or_anonymous(iat_id),
display: source[:ip] || source["ip"]
}
}
case Admin.Clients.create_dcr_client(attrs) do
{:ok, %Client{} = persisted} -> {:ok, persisted}
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Error{code: :persistence_error, reason: changeset}}
{:error, reason} -> {:error, %Error{code: :persistence_error, reason: reason}}
end
end
defp iat_id_or_anonymous(nil), do: "anonymous"
defp iat_id_or_anonymous(id), do: to_string(id)
defp atomize_auth_method("client_secret_basic"), do: :client_secret_basic
defp atomize_auth_method("client_secret_post"), do: :client_secret_post
defp atomize_auth_method("private_key_jwt"), do: :private_key_jwt
defp atomize_auth_method("none"), do: :none
defp atomize_auth_method(_), do: :client_secret_basic
defp client_type_from_auth_method(:none), do: :public
defp client_type_from_auth_method(_), do: :confidential
defp parse_scope(scope) when is_binary(scope) do
scope |> String.split(" ", trim: true) |> Enum.uniq()
end
defp parse_scope(_), do: []
# RFC 7591 §2.3 software_statement is silently ignored (RESEARCH Q6 RESOLVED).
# Only RFC 7591 extension fields we explicitly support land in :metadata JSONB.
defp build_extension_metadata(metadata) when is_map(metadata) do
metadata
|> Map.take(["client_uri"])
|> Map.reject(fn {_k, v} -> is_nil(v) end)
end
```
Step 3 — Replace the Task-2a stubs with the real telemetry helpers (D-25, D-26 — NEVER carry plaintext):
```elixir
defp emit_succeeded(%Client{} = client, iat_record, source) do
iat_id = iat_record && Map.get(iat_record, :id)
Observability.emit(:dcr_registration_succeeded, %{count: 1}, %{
actor_type: :dcr,
actor_id: iat_id_or_anonymous(iat_id),
client_id: client.client_id,
iat_id: iat_id,
source_ip: source[:ip] || source["ip"]
})
end
defp emit_rejected(%Error{} = error, source) do
Observability.emit(:dcr_registration_rejected, %{count: 1}, %{
actor_type: :dcr,
code: error.code,
field: error.field,
reason: error.reason,
source_ip: source[:ip] || source["ip"]
})
end
```
Step 4 — Run the full test suite:
`mix test test/lockspire/protocol/registration_test.exs --max-failures=1` — all tests pass.
`mix qa` — exit 0.
DEFENSE-IN-DEPTH check on telemetry redaction: `grep -E '%\{[^}]*(:rat|:registration_access_token|:client_secret|:plaintext|:initial_access_token)[^}]*\}' lib/lockspire/protocol/registration.ex | grep -E 'Observability\.emit|metadata' | wc -l` returns `0` (zero plaintext keys in any telemetry-metadata literal). Note: `:iat_id` (the integer FK) is OK; `:initial_access_token` (plaintext) is NOT.
AUDIT-ATTRIBUTION check (Task 1's regression-sentinel test): the persistence path goes through `Admin.Clients.create_dcr_client/1` → `actor_from_attrs/1` (tightened in plan 26-01 task 2) → `Audit.Event.normalize/1` → `actor_type` stringified to `"dcr"`. The test asserts `actor_type == "dcr"` on the audit row. If `"operator"` instead, plan 26-01's tightening did not propagate or `attrs.actor.type` was dropped — debug.
ROUND-TRIP `verify_client_secret` check (warning 8 in Task 1): the persisted `client_secret_hash` and the returned `client_secret_plaintext` form a pair. `Policy.verify_client_secret(persisted.client_secret_hash, success.client_secret_plaintext)` returns `true`. If `false`, the persistence path silently regenerated the secret server-side (the original `Lockspire.Clients.register_client/1` bug we routed around with `create_dcr_client/1`).
SOFTWARE_STATEMENT PASSTHROUGH check (warning 7 in Task 1): given metadata containing `"software_statement" => "eyJ..."`, the persisted row's `:metadata` JSONB does NOT contain the key `"software_statement"` (we only `Map.take(metadata, ["client_uri"])`); registration succeeds normally. RESEARCH Q6 RESOLVED.
<threat_model>
| Boundary | Description |
|---|---|
Inbound RFC 7591 metadata → Registration.register/1 |
Untrusted client-controlled JSON crosses into the validator. The validator is the only thing standing between attacker-shaped metadata and a persisted Domain.Client row. |
Plaintext IAT (caller-supplied) → maybe_redeem_iat/1 |
Crosses into the module exactly once; immediately delegated to InitialAccessToken.redeem/1 (Wave 1) which hashes and collapses errors. |
Generated credentials (client_secret, RAT) → Success substruct → Phase 27 controller |
Plaintext is returned to the immediate caller exactly once; the persisted row holds only hashes. Audit + telemetry emission paths must NOT see the plaintext. |
Audit-event metadata → lockspire_audit_events JSONB columns |
Must be redacted via Audit.Event.normalize/1 → Lockspire.Redaction.for_audit/1. |
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-26-INTAKE-INJECTION | Tampering | Intake validator (private functions in Registration) |
mitigate | D-14 enforcement: jwks_uri rejected outright; jwks ⊕ jwks_uri mutual exclusion; RFC 7591 §2 grant/response coherence via lookup table; redirect_uris routed through the operator-shared Lockspire.Clients.validate_redirect_uris/1. D-15 enforcement: explicit pkce_required: false rejected with :pkce_floor_required_for_dcr. The persisted row's pkce_required is hardcoded to true regardless of inbound value. Tests in Task 1's describe "register/1 — D-14 validator" and describe "register/1 — D-15 PKCE floor" are the closing automated proofs. |
| T-26-RAT-LEAK | Information Disclosure | Registration.register/1 post-commit telemetry |
mitigate | The emit_succeeded and emit_rejected helpers pass only :client_id, :iat_id, :source_ip, :actor_type, :actor_id, :code, :field, :reason into Observability.emit/3 — never credentials.rat, credentials.client_secret, or metadata containing inbound jwks keys. The acceptance-criteria grep enforces this. The Lockspire.Redaction.for_telemetry/1 sieve (:client_secret, :token, :token_hash, :authorization) is the second line of defense. Wave 3 plan 26-07's cross-cutting sweep test is the closing proof. |
| T-26-IAT-LEAK | Information Disclosure | maybe_redeem_iat/1 plaintext path |
mitigate | Plaintext IAT enters register/1 via request.iat and is passed once to InitialAccessToken.redeem/1 (which hashes immediately). The plaintext is NEVER stored in any local that ends up in metadata. After redeem/1 returns, only the %IatDomain{id, ...} struct (no plaintext field) is in scope. |
| T-26-SECRET-LEAK | Information Disclosure | client_secret plaintext path |
mitigate | Clients.rotate_secret_hash/0 returns {hash, plaintext} — the orchestrator places the hash on the persisted row's client_secret_hash and returns the plaintext on the Success substruct (D-17). No telemetry/log/audit metadata literal carries :client_secret plaintext. |
| T-26-ATTRIBUTION-DRIFT | Repudiation | Audit row attribution on DCR write | mitigate | attrs.actor = %{type: :dcr, id: iat_id_or_"anonymous", display: source.ip} is set explicitly at persist_client/5 (D-23). The tightened actor_from_attrs/1 from plan 26-01 raises if :dcr is missing or blank — fail loud, never silently :operator. The dcr_audit_attribution_test.exs regression test at plan 26-07 is the final sweep. |
| T-26-IAT-ENUM | Information Disclosure (enumeration) | maybe_redeem_iat/1 collapse |
mitigate | InitialAccessToken.redeem/1 already collapses 4 axes to :invalid_token (Wave 1 plan 26-03). maybe_redeem_iat/1 re-wraps as %Error{code: :invalid_token} — no axis discriminator leaks to the caller. The discriminator is preserved in :iat_redemption_failed telemetry only. |
| T-26-PKCE-DOWNGRADE | Tampering | pkce_required floor enforcement |
mitigate | Two-line defense: (1) validate_pkce_floor/1 rejects explicit pkce_required: false with :pkce_floor_required_for_dcr (D-15); (2) persist_client/5 hardcodes pkce_required: true on the persisted row regardless of inbound. Test in Task 1's describe "register/1 — D-15 PKCE floor" covers both legs. |
| T-26-POLICY-BYPASS | Elevation of Privilege | Anonymous registration when policy demands IAT | mitigate | RESEARCH Q5 RESOLVED: require_iat_when_policy_demands/2 fires BEFORE maybe_redeem_iat/1. When server_policy.registration_policy == :initial_access_token AND iat == nil, returns %Error{code: :invalid_token, field: :iat, reason: :missing}. No call to InitialAccessToken.redeem/1 happens (no IAT-redemption-failure telemetry leaks the policy state). Test in Task 1's describe "register/1 — IAT precondition gate (D-13 step 1 — RESEARCH Q5 RESOLVED)". |
| T-26-DCR-PERSIST-LOSS | Tampering / Repudiation | Persistence path correctness | mitigate | The naive route — Lockspire.Clients.register_client/1 → normalize/1 — silently strips provenance, registration_access_token_hash, initial_access_token_id, client_id_issued_at, client_secret_expires_at, AND regenerates client_secret_hash server-side (so the plaintext returned to the caller would not match the persisted hash). Plan 26-05 persist_client/5 routes through Admin.Clients.create_dcr_client/1 (plan 26-01 task 4) which writes the fully-formed %Domain.Client{} verbatim through Repository.register_client/1. Acceptance grep grep -c 'Lockspire.Clients.register_client' lib/lockspire/protocol/registration.ex returns 0. The round-trip Policy.verify_client_secret(persisted.client_secret_hash, success.client_secret_plaintext) == true test in Task 1 is the closing automated proof. |
| T-26-EXTENSION-FIELD-LEAK | Information Disclosure (storage of unvetted material) | RFC 7591 extension fields including software_statement |
mitigate | RESEARCH Q6 RESOLVED: silently ignore unknown fields. build_extension_metadata/1 calls Map.take(metadata, ["client_uri"]) — only the v1.5-supported extension lands in the :metadata JSONB column. software_statement (RFC 7591 §2.3 — a JWT possibly containing third-party PII) is dropped. Test in Task 1: "register/1 silently ignores software_statement". |
| </threat_model> |
<success_criteria>
- All tests in
test/lockspire/protocol/registration_test.exspass. - DCR-02 (intake validation), DCR-03 (PKCE floor), DCR-04 (credential issuance + hash-at-rest, round-trip-proven) close at the protocol-module layer.
- The orchestrator follows the
pushed_authorization_request.exstructural template (D-01). - The validator pipeline is
@doc false def(publicly callable but undocumented) so plan 26-06'sRegistrationManagement.update/2can reuse it (D-02). - The persistence path routes through
Admin.Clients.create_dcr_client/1— DCR fields preserved verbatim, never silently regenerated. - The precondition gate (RESEARCH Q5 RESOLVED) closes the policy-bypass axis before any other pipeline step.
- Unknown RFC 7591 fields including
software_statementare silently ignored. </success_criteria>