Lockspire Code Walkthrough

Copy Markdown

This guide begins where the architecture guide stops. The architecture guide names the boundaries; this one follows values through the code that enforces them.

Hold one route in your head:

request parameters
  -> validated authorization value
  -> durable interaction
  -> hashed authorization code
  -> bound token exchange
  -> access and refresh records
  -> row-locked refresh family

The excerpts below are copied from current source and shortened. Every deliberate cut is marked with # .... Internal modules and private functions are shown to explain the design; they are not promises of public API. The public ceiling remains the supported surface.

Boot: host configuration becomes runtime mechanics

Lockspire is a library application, but it is not passive. The host supplies the repository, account resolver, issuer, mount, prefixes, and Oban configuration. Lockspire.Config turns missing or unsafe values into early failures. Lockspire.Application starts only Lockspire-owned runtime children.

def issuer! do
  issuer = fetch_required!(:issuer)
  mount_path = mount_path()
  signing_alg = Application.get_env(@app, :signing_alg, "RS256")

  Policy.validate_issuer_and_mount_path!(issuer, mount_path)
  Policy.validate_signing_alg!(signing_alg)

  issuer
end

def start(_type, _args) do
  children = [
    {Lockspire.Oban, Lockspire.Oban.runtime_config!()},
    Cachex.child_spec(name: :lockspire_jwks_cache),
    Lockspire.KeyCache
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: Lockspire.Supervisor)
end

The repository and Phoenix endpoint remain host processes. Lockspire supervises its named Oban runtime and key caches. That is the embedded shape in OTP terms: shared host infrastructure, separately owned protocol workers.

Configuration is read when a protocol boundary needs it, and the install verifier probes the full required set. Invalid issuer/mount combinations and forbidden signing algorithms fail before a request can become partial protocol state.

Install: generated files get owners

Lockspire.Generators.Install renders both ownership classes, but the manifest receives only managed templates. Rerunning the task leaves identical files alone and refuses to overwrite drift.

defp write_manifest!(assigns, rendered_templates) do
  managed_templates =
    rendered_templates
    |> Enum.filter(&(&1.template.ownership == :managed))

  assigns
  |> Manifest.build(managed_templates)
  |> then(&Manifest.write(assigns.project_root, &1))
end

defp ensure_file!(destination, rendered) do
  File.mkdir_p!(Path.dirname(destination))

  case File.read(destination) do
    {:ok, ^rendered} ->
      Mix.shell().info("* unchanged #{Path.relative_to_cwd(destination)}")

    {:ok, _existing} ->
      Mix.raise("""
      Refusing to overwrite modified file: #{Path.relative_to_cwd(destination)}

      Keep the host-owned edits and reconcile this file manually before rerunning
      `mix lockspire.install`.
      """)

    {:error, :enoent} ->
      File.write!(destination, rendered)
      Mix.shell().info("* created #{Path.relative_to_cwd(destination)}")

    {:error, reason} ->
      Mix.raise("Could not read #{Path.relative_to_cwd(destination)}: #{inspect(reason)}")
  end
end

That filter is the upgrade boundary. The generated account, interaction, consent, device, and product UX modules remain host-owned even though Lockspire created their first version.

Lockspire.Install.Verify then checks the assembled application rather than assuming generation was enough:

def run(opts \\ []) do
  router = Keyword.fetch!(opts, :router)
  resolver_module = Keyword.fetch!(opts, :resolver_module)
  interaction_handler_module = Keyword.fetch!(opts, :interaction_handler_module)
  repo = Keyword.get(opts, :repo, Lockspire.Config.repo!())
  mount_path = Keyword.get(opts, :mount_path, Lockspire.Config.mount_path())

  checks = [
    config_check(),
    seam_modules_check(resolver_module, interaction_handler_module),
    router_check(router, mount_path),
    migrations_check(repo)
  ]

  %{ok?: Enum.all?(checks, &(&1.status == :ok)), checks: checks}
end

The router check is deliberately order-aware: the guarded admin forward must precede the public Lockspire forward. The migration check covers both the protocol tables and the named Oban runtime in their configured prefixes.

Test lens — generated ownership. The install-generator integration tests assert the manifest contains managed config, router, and smoke-test files but not the account resolver. They also prove unchanged reruns and overwrite refusal. Lockspire.Install.VerifyTest covers missing /verify routes, a missing admin guard, and public-forward shadowing.

Mount: web delivery stays thin

Lockspire.Web.Router makes the endpoint topology visible. Most routes enter a small controller. The ordinary authorization, token, and userinfo routes share the FAPI boundary so resolved client/server policy can make stricter profiles mandatory without creating a second router.

pipeline :fapi_boundary do
  plug(Lockspire.Protocol.FAPI20EnforcerPlug)
end

scope "/" do
  get("/.well-known/openid-configuration", Lockspire.Web.DiscoveryController, :show)
  get("/jwks", Lockspire.Web.JwksController, :index)
  post("/par", Lockspire.Web.PushedAuthorizationRequestController, :create)
  post("/revoke", Lockspire.Web.RevocationController, :create)
  post("/introspect", Lockspire.Web.IntrospectionController, :create)
  # ...
  scope "/" do
    pipe_through(:fapi_boundary)

    get("/authorize", Lockspire.Web.AuthorizeController, :show)
    post("/token", Lockspire.Web.TokenController, :create)
    get("/userinfo", Lockspire.Web.UserinfoController, :show)
  end
end

The router tells you where a value enters. The protocol coordinator tells you what it means.

Cross the host seam

The host boundary is expressed as values, not callbacks that can mutate Lockspire internals. Lockspire.Host.AccountResolver receives a connection-like value and a Lockspire.Host.Context; it returns a host account, claims, or an interaction handoff.

@callback resolve_current_account(conn_or_socket :: connection(), context()) ::
            {:ok, account()} | {:redirect, InteractionResult.t()}

@callback resolve_account(account_reference :: term(), context()) ::
            {:ok, account()} | {:error, :not_found | term()}

@callback build_claims(account(), context()) ::
            {:ok, Claims.t()} | {:error, term()}

def build_id_token_claims(%__MODULE__{} = claims, protocol_claims) do
  claims.id_token
  |> Map.drop(@protocol_claims)
  |> Map.put("sub", claims.subject)
  |> Map.merge(protocol_claims)
  |> drop_nil_claims()
end

Notice the merge order. Host-provided claim maps cannot replace protocol claims such as iss, aud, exp, nonce, auth_time, sub, or sid. The host is authoritative for source data; Lockspire is authoritative for the token's protocol envelope.

The generated resolver's explanatory exception currently shows a stale %Claims{claims: ...} illustration. The real struct has subject, id_token, and userinfo fields, as the code above shows.

Validate before involving a person

Lockspire.Web.AuthorizeController.show/2 has three top-level outcomes. It asks the host for a subject only after Lockspire.Protocol.AuthorizationRequest returns a canonical validated value.

def show(conn, params) do
  case AuthorizationRequest.validate(params) do
    {:ok, %Validated{} = validated} ->
      with {:ok, subject_context} <- resolve_subject_context(conn, validated),
           outcome <-
             AuthorizationFlow.start_authorization(
               validated,
               subject_context,
               protocol_store_opts()
             ) do
        handle_authorization_outcome(conn, outcome)
      else
        {:error, %Error{} = error} ->
          render_browser_error(conn, error, :internal_server_error)
      end

    {:browser_error, %Error{} = error} ->
      render_browser_error(conn, error, :bad_request)

    {:redirect_error, %Error{} = error} ->
      redirect(conn, external: redirect_location(error))
  end
end

Browser-safe and redirect-safe errors are different types because an OAuth error redirect is itself a security decision. validate_redirect_uri/2 must succeed before later validation can trust the URI.

defp validate_redirect_uri(client, %{"redirect_uri" => redirect_uri})
     when is_binary(redirect_uri) and redirect_uri != "" do
  if redirect_uri in client.redirect_uris do
    {:ok, redirect_uri}
  else
    {:browser_error,
     browser_error(
       :invalid_request,
       "redirect_uri must match a registered URI",
       :invalid_redirect_uri
     )}
  end
end

# ... the S256-specific acceptance clause runs before this fallback

defp validate_pkce(client, params, opts) do
  security_profile = Keyword.get(opts, :security_profile, %SecurityProfile.Resolved{})

  cond do
    security_profile.fapi_2_0_security? ->
      {:redirect_error,
       redirect_error(params, :invalid_request, "PKCE S256 is required", :missing_pkce)}

    client.pkce_required ->
      {:redirect_error,
       redirect_error(params, :invalid_request, "PKCE S256 is required", :missing_pkce)}

    true ->
      :ok
  end
end

The exact membership check is the entire redirect-matching policy. No URI normalizer repairs a slash or query string. The preceding S256-specific clause, omitted here, accepts only a valid S256 challenge; this fallback rejects a missing or different method when policy requires PKCE.

Turn browser work into durable state

Lockspire.Protocol.AuthorizationFlow.start_authorization/3 decides whether the request can run silently, must enter host login, or can proceed with the current subject.

def start_authorization(%Validated{} = validated, subject_context, opts \\ []) do
  now = now(opts)
  interaction_id = generate_interaction_id(opts)

  cond do
    silent_prompt?(validated.prompt) ->
      start_silent_authorization(validated, subject_context, interaction_id, now, opts)

    login_required?(validated, subject_context, now) ->
      validated
      |> build_interaction(interaction_id, nil, :pending_login, now)
      |> persist_login_required(opts)

    true ->
      start_subject_authorization(validated, subject_context, interaction_id, now, opts)
  end
end

The value crossing into persistence is a Lockspire.Domain.Interaction. It contains the exact redirect URI, requested scopes/resources, state, nonce, PKCE challenge, response mode, subject and authentication time when known, and one of five statuses: pending login, pending consent, completed, denied, or expired.

Approval is a guarded transition. The expected status is passed to storage, so two browser submissions cannot both complete the interaction. Consent and the authorization code are created inside the same audited operation.

defp issue_authorization_code(%Interaction{} = interaction, subject_id, consent_grant_id, opts) do
  raw_code = generate_code(opts)
  now = now(opts)
  token_hash = Policy.hash_token(raw_code)

  token = %Token{
    token_hash: token_hash,
    token_type: :authorization_code,
    client_id: interaction.client_id,
    account_id: subject_id,
    interaction_id: interaction.interaction_id,
    consent_grant_id: consent_grant_id,
    sid: interaction.sid,
    redirect_uri: interaction.redirect_uri,
    scopes: interaction.scopes_requested,
    audience: interaction.resources_requested,
    code_challenge: interaction.code_challenge,
    code_challenge_method: interaction.code_challenge_method,
    issued_at: now,
    expires_at: DateTime.add(now, @authorization_code_ttl, :second)
  }

  with {:ok, stored_token} <- token_store(opts).store_token(token) do
    emit(:authorization_code, :issued, interaction, subject_id, %{token_id: stored_token.id})
    approval_redirect(interaction, raw_code, opts)
  end
end

Plaintext and durable state split at raw_code/token_hash. The redirect gets the former. PostgreSQL gets the latter plus every binding needed at redemption.

Test lens — interaction state. Lockspire.Protocol.AuthorizationFlowTest proves login and consent states are durable, remembered consent is scope-bounded, prompt=none does not invoke UI, approval stores only a hash, and expired or duplicate completion fails. Its audit assertions are part of the state-machine contract, not incidental logging.

Redeem the authorization code

Lockspire.Web.TokenController packages form parameters, client authorization, DPoP proof, HTTP method, mTLS certificate context, secret-key base, and storage implementations into a request map for Lockspire.Protocol.TokenExchange. It also enforces no-store and no-cache on both success and failure responses.

Grant dispatch happens before grant-specific work, but authorization-code exchange has a strict sequence: authenticate the client, resolve DPoP context, load the code by hash, check activity, then check every original binding.

defp validate_code_binding(%Client{} = client, %Token{} = authorization_code, params) do
  with :ok <- validate_client_binding(client, authorization_code),
       :ok <- validate_redirect_uri_binding(authorization_code, params) do
    validate_pkce_binding(authorization_code, params)
  end
end

defp validate_redirect_uri_binding(%Token{} = authorization_code, params) do
  redirect_uri = normalize_optional_string(params["redirect_uri"])

  if redirect_uri == authorization_code.redirect_uri do
    :ok
  else
    {:error,
     invalid_grant(
       "redirect_uri does not match the issued authorization code",
       :redirect_uri_mismatch
     )}
  end
end

The PKCE check hashes the presented verifier and compares it with the stored challenge. Resource Indicators are checked against the authorized audience, not treated as a chance to broaden it.

After token construction, redemption and persistence move together with the durable audit event:

defp persist_authorization_code_grant(
       code_hash,
       issued_at,
       %Token{} = access_token,
       %Token{} = authorization_code,
       nil,
       _issuance_context,
       request
     ) do
  audit_event =
    redemption_audit_event(client_actor(authorization_code.client_id), authorization_code)

  case transact_with_audit_event(token_store(request), audit_event, fn ->
         token_store(request).redeem_authorization_code(code_hash, issued_at, access_token)
       end) do
    {:ok, %{access_token: %Token{} = persisted_access_token}} ->
      {:ok, %{access_token: persisted_access_token}}

    {:error, reason} ->
      {:error, reason}
  end
end

The branch that also creates a refresh token wraps code redemption and initial family persistence in the same outer transaction. This prevents a consumed code from being committed without the token set the response claims to have issued.

Sign, format, and forget plaintext

Lockspire.Protocol.AccessTokenSigner is the shared access-token issuance boundary. Client policy may choose JWT or opaque format. In the JWT branch, the active server key supplies alg and kid; client input never does.

defp sign_jwt(claims, request) do
  with {:ok, %{kid: kid, alg: alg, private_jwk_encrypted: private_jwk}} <-
         fetch_signing_key(request),
       {:ok, jwk_map} <- decode_private_jwk(private_jwk) do
    {_, compact} =
      JOSE.JWT.sign(
        JOSE.JWK.from_map(jwk_map),
        %{"alg" => alg, "kid" => kid, "typ" => "at+jwt"},
        claims
      )
      |> JOSE.JWS.compact()

    {:ok, compact, Policy.hash_token(compact)}
  else
    {:error, reason} ->
      Logger.error("Failed to sign access token: #{inspect(reason)}")

      {:error,
       %Error{
         status: 500,
         error: "server_error",
         error_description: "Unable to sign access token.",
         reason_code: :token_signing_failed
       }}
  end
end

def hash_token(token) when is_binary(token) do
  :sha256 |> :crypto.hash(token) |> Base.encode16(case: :lower)
end

The raw compact JWT or opaque random value returns to the caller. The durable record is re-pointed to the hash of the exact wire token. Authorization codes, refresh tokens, opaque access tokens, and client secrets follow the same bounded plaintext principle at their respective issuance edges.

Test lens — code binding and issuance. Lockspire.Protocol.TokenExchangeTest covers client, redirect, verifier, expiry, and replay failures; JWT and opaque formats; ID-token conditions; refresh-family creation; DPoP binding; hashes at rest; and durable audit rows. The phase-three OIDC lifecycle integration test drives the matching behavior through HTTP.

Storage makes atomicity explicit

The protocol depends on a behavior, not Ecto calls scattered through every coordinator. The important callbacks state which compound operations must be atomic.

@callback redeem_authorization_code(String.t(), DateTime.t(), Token.t()) ::
            {:ok, %{authorization_code: Token.t(), access_token: Token.t()}}
            | {:error, store_error()}

@callback rotate_refresh_token(
            String.t(),
            String.t(),
            DateTime.t(),
            Token.t(),
            Token.t(),
            expected_cnf()
          ) ::
            {:ok,
             %{
               presented_refresh_token: Token.t(),
               refresh_token: Token.t(),
               access_token: Token.t()
             }}
            | {:error, store_error()}

Lockspire.Storage.Ecto.Repository implements code redemption by locking the credential row before changing it and inserting the access token.

def redeem_authorization_code(token_hash, redeemed_at, %Token{} = access_token)
    when is_binary(token_hash) and is_struct(redeemed_at, DateTime) do
  transact(fn ->
    TokenRecord
    |> where([token], token.token_hash == ^token_hash)
    |> where([token], token.token_type == :authorization_code)
    |> lock("FOR UPDATE")
    |> repo_one(sensitive: true)
    |> redeem_authorization_code_record(redeemed_at, access_token)
  end)
end

The sensitive: true path controls inspection and query logging around token records. The lock controls concurrency. The transaction controls contradictory durable outcomes.

Rotate a refresh family atomically

Lockspire.Protocol.RefreshExchange hashes the presented token, validates its client/resource/sender context, constructs child values, and delegates the family mutation to the store. It interprets reuse as a durable denial rather than a generic lookup failure.

# ... access and refresh token values have been constructed

case store.rotate_refresh_token(
       refresh_token_hash,
       client.client_id,
       rotated_at,
       refresh_token,
       access_token,
       expected_cnf
     ) do
  {:ok,
   %{
     presented_refresh_token: %Token{} = presented,
     refresh_token: %Token{} = persisted_refresh_token,
     access_token: %Token{}
   } = success} ->
    {:ok, success, [refresh_rotation_audit_event(client, presented, persisted_refresh_token)]}

  {:error, :reuse_detected} ->
    {:durable_error,
     invalid_grant(
       "Refresh token reuse detected; the token family has been revoked",
       :refresh_token_reuse_detected
     ), reuse_audit_events(client, presented_refresh_token)}

  {:error, reason} ->
    {:error, refresh_rotation_error(reason)}
end

# ... the audited transaction maps the durable outcome to an OAuth response

The repository transaction locks the presented family member. The state of that one row decides between ordinary rotation and containment. These helpers show the serialization point and the two pieces of reuse evidence written by the reuse branch:

defp locked_refresh_token_query(token_hash) do
  TokenRecord
  |> where([token], token.token_hash == ^token_hash)
  |> where([token], token.token_type == :refresh_token)
  |> lock("FOR UPDATE")
end

defp mark_refresh_token_reuse(%TokenRecord{} = record, detected_at, updated_at) do
  record
  |> Ecto.Changeset.change(
    reuse_detected_at: record.reuse_detected_at || detected_at,
    updated_at: updated_at
  )
  |> repo_update(sensitive: true)
  |> map_one(&TokenRecord.to_domain/1)
end

defp revoke_token_family_records(family_id, revoked_at, updated_at) do
  {count, _records} =
    TokenRecord
    |> where([token], token.family_id == ^family_id)
    |> repo_update_all(
      [set: [revoked_at: revoked_at, updated_at: updated_at]],
      [sensitive: true],
      inc: []
    )

  {:ok, count}
rescue
  error -> {:error, error}
end

The coordinator excerpt omits the construction around the store call. On reuse, the repository branch records reuse_detected_at, revokes active family members, appends reuse and family-revocation audit events, and commits before returning invalid_grant.

Test lens — concurrency and containment. Lockspire.Protocol.RefreshExchangeTest proves normal child generation, preserved cnf, no mutation on binding mismatch, ancestor replay, family-wide revocation, and explicit audit reason codes. The repository assertions are what make the coordinator's security claim credible.

Observe without leaking

Operational telemetry and durable audit evidence take different paths. Lockspire.Observability redacts metadata before emitting both the ordinary and audit-prefixed telemetry events. Lockspire.Audit.Event separately normalizes records that storage can commit.

def emit(entity, action, measurements, metadata) do
  redacted_metadata = redact(metadata)
  normalized_measurements = Map.put_new(measurements, :count, 1)

  :telemetry.execute(
    @audit_prefix ++ [entity, action],
    normalized_measurements,
    redacted_metadata
  )

  :telemetry.execute(
    @telemetry_prefix ++ [entity, action],
    normalized_measurements,
    redacted_metadata
  )

  :ok
end

def normalize(%__MODULE__{} = event) do
  %__MODULE__{
    event
    | metadata: event.metadata |> Redaction.for_audit() |> compact_metadata()
  }
end

The [:lockspire, :audit, ...] name is still a telemetry event. A persisted Lockspire.Audit.Event is durable incident evidence. Both redact raw tokens, secrets, verifier material, request bodies, and other unsafe values; telemetry also replaces refresh family IDs with correlation handles.

A secondary boundary: protect a host API

The resource-server route repeats the same separation of protocol truth and product policy. The order is intentional:

pipeline :lockspire_protected_api do
  plug Lockspire.Plug.VerifyToken,
    scopes: ["read:billing"],
    audience: "https://api.example.test/billing",
    enforce_audience: true

  plug Lockspire.Plug.EnforceSenderConstraints,
    dpop_replay_store: MyAppWeb.ProtectedApiReplayStore

  plug Lockspire.Plug.RequireToken
end

VerifyToken establishes signature or opaque-token facts and applies route scope/audience restrictions. EnforceSenderConstraints proves DPoP or mTLS binding. RequireToken fails closed if a bound token reaches it without verified binding. Only after that does host code decide tenant membership, object access, plan limits, or another business rule. The task-level contract lives in Protect Phoenix API routes.

Next source-reading sessions

Each route below has one question. Stop when you can answer it from the source and its focused tests.

  1. Why may this browser redirect happen? Read Lockspire.Web.AuthorizeController, Lockspire.Protocol.AuthorizationRequest, then Lockspire.Protocol.AuthorizationFlow alongside Lockspire.Protocol.AuthorizationFlowTest.
  2. Can code redemption leave partial state? Read Lockspire.Web.TokenController, Lockspire.Protocol.TokenExchange, Lockspire.Storage.TokenStore, and the narrow authorization-code functions in Lockspire.Storage.Ecto.Repository; compare Lockspire.Protocol.TokenExchangeTest.
  3. What happens when two presenters use one refresh generation? Read Lockspire.Protocol.RefreshExchange, the row-locked refresh functions in Lockspire.Storage.Ecto.Repository, and Lockspire.Protocol.RefreshExchangeTest.
  4. Which generated files may an upgrade replace? Read Lockspire.Generators.Templates, Lockspire.Generators.Install, Lockspire.Install.Manifest, and Lockspire.Install.Verify; then read the install-generator and verifier integration tests.
  5. Where does a protected request become host policy? Read Lockspire.Plug.VerifyToken, Lockspire.Plug.EnforceSenderConstraints, and Lockspire.Plug.RequireToken, then the host route that runs after the pipeline.
  6. How does an advanced grant reuse the core? Start at the matching web controller, follow its protocol coordinator to its domain record and storage behavior, and find where it converges on token issuance and audit evidence.

Return to the architecture guide when you need the larger ownership map, and to the supported surface before treating any readable internal module as a compatibility promise.