Skip to content

feat: migrate KSeF client from API v1 to v2#34

Merged
emilwojtaszek merged 5 commits into
mainfrom
feat/ksef-api-v2-migration
Feb 11, 2026
Merged

feat: migrate KSeF client from API v1 to v2#34
emilwojtaszek merged 5 commits into
mainfrom
feat/ksef-api-v2-migration

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Feb 11, 2026

Copy link
Copy Markdown
Member

The KSeF production API v1 (/api/online on ksef.mf.gov.pl) has been shut down, returning HTML instead of JSON and causing AuthWorker crashes.

Key changes:

  • Base URLs: ksef-test.mf.gov.pl → api-test.ksef.mf.gov.pl (test), ksef.mf.gov.pl → api.ksef.mf.gov.pl (prod)
  • Path prefix: /api/online → /v2
  • Auth header: custom SessionToken → standard Authorization: Bearer
  • Auth flow: operation_token → auth_token with nested token objects
  • XML namespace: v1 AuthorisationChallengeRequest → v2 AuthTokenRequest with certificateSubject identifier type
  • Query format: flat timestamp thresholds → nested dateRange with PermanentStorage type, pagination moved to query params
  • terminate_session: GET → DELETE /auth/sessions/current
  • HTML response guards on all endpoints to prevent FunctionClauseError

Summary by CodeRabbit

  • New Features

    • Upgraded to KSeF API v2 with new endpoints and expanded authentication payload (auth token, expiry, refresh metadata).
  • Improvements

    • Broadened API error handling, HTML/binary responses, rate‑limit handling, and richer invoice query/download behavior.
    • Updated XML signing/request format for v2 and improved xmlsec1 compatibility/error reporting across versions.
    • Runtime now reads DB pool size from env in development.
  • Behavior Changes

    • Sync jobs store cancellation meta and return explicit cancellation reasons when credentials/certificates are missing.
  • Configuration

    • Updated test and production KSeF API URLs to v2 endpoints.
  • Tests

    • Updated mocks/fixtures to match the new authentication token shape.

The KSeF production API v1 (/api/online on ksef.mf.gov.pl) has been
shut down, returning HTML instead of JSON and causing AuthWorker crashes.

Key changes:
- Base URLs: ksef-test.mf.gov.pl → api-test.ksef.mf.gov.pl (test),
  ksef.mf.gov.pl → api.ksef.mf.gov.pl (prod)
- Path prefix: /api/online → /v2
- Auth header: custom SessionToken → standard Authorization: Bearer
- Auth flow: operation_token → auth_token with nested token objects
- XML namespace: v1 AuthorisationChallengeRequest → v2 AuthTokenRequest
  with certificateSubject identifier type
- Query format: flat timestamp thresholds → nested dateRange with
  PermanentStorage type, pagination moved to query params
- terminate_session: GET → DELETE /auth/sessions/current
- HTML response guards on all endpoints to prevent FunctionClauseError

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Feb 11, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Migrates KSeF integration to API v2: updates env/config URLs, refactors REST client to v2 endpoints and Bearer auth, renames operation_token→auth_token across behaviour/implementation/tests, updates XAdES AuthTokenRequest XML, enhances xmlsec1 handling, changes sync worker cancellation semantics, and updates tests and lockfile.

Changes

Cohort / File(s) Summary
Configuration & Env
\.env.example, config/config.exs, config/runtime.exs
Updated KSeF hosts to v2 (api-test.ksef.mf.gov.pl, api.ksef.mf.gov.pl), set default KSEF_API_URL, and make dev repo pool_size read from POOL_SIZE.
KSeF API client (v2)
lib/ksef_hub/ksef_client/live.ex
Reworked client to v2 paths (/v2/auth/*, /v2/invoices/*), switch to Bearer auth, add 429/retry and binary/HTML handling, and introduce helpers for query/date parsing and Retry-After extraction.
Auth flow & behaviour
lib/ksef_hub/ksef_client/behaviour.ex, lib/ksef_hub/ksef_client/auth.ex
Renamed operation_tokenauth_token across behaviour and implementation; authenticate_xades/poll/redeem signatures and return maps extended with auth token validity, refresh token and expiration fields; code updated to use auth_token.
XAdES / Auth token XML
lib/ksef_hub/xades_signer/auth_token_request.ex
Replaced AuthorisationChallengeRequest with AuthTokenRequest v2 namespace; switch ContextIdentifier to Nip, embed ds:Signature, add XML escaping helper and @spec for build/2.
xmlsec1 signer
lib/ksef_hub/xades_signer/xmlsec1.ex
Added version-aware password handling (--pwd vs --pwd-file) with supports_pwd_file?/0 and build_password_args/1, improved System.cmd error mapping, resource cleanup, and added @spec for sign_challenge.
Sync worker semantics
lib/ksef_hub/sync/sync_worker.ex, test/ksef_hub/sync/sync_worker_test.exs
When missing credentials or owner certificate, store meta, log warning and return {:cancel, :no_credential} / {:cancel, :no_certificate} instead of previously returning :ok; tests adjusted accordingly.
Tests & Mocks
test/ksef_hub/ksef_client/*, test/ksef_hub_web/live/certificate_live_test.exs, test/.../auth_worker_test.exs
Mocks and expectations updated to use auth_token and auth_token_valid_until; poll/redeem test flows updated to match v2 payloads.
Misc / Manifest
mix.lock
Lockfile updated (dependency resolution changes).

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Signer as XAdESSigner
  participant API as KSeF_API

  Client->>API: POST /v2/auth/challenge (empty JSON)
  API-->>Client: 200 {reference_number, challenge}
  Client->>Signer: sign challenge -> signed_xml
  Signer-->>Client: signed_xml
  Client->>API: POST /v2/auth/xades-signature (signed_xml)
  API-->>Client: 202 {reference_number, auth_token, auth_token_valid_until}
  loop poll
    Client->>API: GET /v2/auth/{reference} (Authorization: Bearer auth_token)
    API-->>Client: 200 {status: pending|success|error, ...}
  end
  alt success
    Client->>API: POST /v2/auth/token/redeem (Authorization: Bearer auth_token)
    API-->>Client: 200 {access_token, refresh_token, access_valid_until, refresh_valid_until}
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Feat/certificate page redesign #28: touches KSeF authentication flow and token naming; closely related to renaming operation_tokenauth_token and auth payload changes.
  • Feat/key crt upload #27: modifies XAdES/xmlsec invocation and temp-file/password handling; overlaps with xmlsec1 and signing logic updates.
  • Feat/multi company support #21: updates sync_worker control flow and cancellation semantics, related to the sync cancellation changes here.

Poem

🐰 I hopped from old hosts to v2's bright gate,
I signed the challenge, fetched a token, then I wait,
Bearer headers hum, xmlsec hums along,
Jobs cancel with reasons when the certs go wrong,
I thump my foot — v2's the new carrot crate. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: migrating the KSeF client from API v1 to v2, which is the core objective across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/ksef-api-v2-migration

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/ksef_hub/ksef_client/live.ex (2)

148-184: ⚠️ Potential issue | 🟠 Major

Use RateLimiter to enforce proactive rate limiting instead of only reacting to 429 responses.

The client currently lacks proactive rate limiting. A RateLimiter module already exists in the codebase with built-in support for operation-type-specific rate windows. Call RateLimiter.wait_for_slot(:metadata) before the Req.post request to enforce the query rate limit before making the request, rather than waiting for the server to reject it.


187-205: ⚠️ Potential issue | 🟠 Major

Add rate limiting to invoice downloads (8 req/s).
The download_invoice function is called in a tight loop without throttling, which can exceed KSeF's recommended rate limits. Add Process.sleep(125) before the HTTP request to throttle to 8 req/s.

🔧 Suggested change
 def download_invoice(access_token, ksef_number) do
   url = api_url("/invoices/ksef/#{ksef_number}")
   headers = bearer_headers(access_token)
+
+  Process.sleep(125)
 
   case Req.get(url, headers: headers) do
🧹 Nitpick comments (4)
lib/ksef_hub/ksef_client/live.ex (2)

9-15: Add @doc/@SPEC for public functions and @spec for base_url/api_url/bearer_headers.
These functions are missing required docs/specs in this module.

As per coding guidelines “Every public function must have @doc documentation” and “Every function (public and private) must have @spec type specification.”

Also applies to: 17-37, 40-63, 66-90, 93-119, 122-145, 148-184, 187-205, 208-217


221-253: Consider piping the dateRange build to follow the transformation-chain guideline.
You can express the conditional map updates as a pipeline while keeping the same behavior.

♻️ Possible refactor
 defp build_date_range(from, to) do
-  range = %{"dateType" => "PermanentStorage"}
-  range = if from, do: Map.put(range, "from", DateTime.to_iso8601(from)), else: range
-  range = if to, do: Map.put(range, "to", DateTime.to_iso8601(to)), else: range
-  range
+  [{"from", from}, {"to", to}]
+  |> Enum.reduce(%{"dateType" => "PermanentStorage"}, fn
+    {_, nil}, acc -> acc
+    {key, dt}, acc -> Map.put(acc, key, DateTime.to_iso8601(dt))
+  end)
 end
As per coding guidelines “Use `|>` pipelines for data transformation chains in Elixir.”
lib/ksef_hub/ksef_client/auth.ex (2)

28-43: Missing @spec for public function authenticate/3.

Per coding guidelines, every function (public and private) must have a @spec type specification.

🔧 Proposed fix
+  `@spec` authenticate(String.t(), binary(), String.t()) ::
+          {:ok,
+           %{
+             access_token: String.t(),
+             refresh_token: String.t(),
+             access_valid_until: DateTime.t(),
+             refresh_valid_until: DateTime.t()
+           }}
+          | {:error, term()}
   def authenticate(nip, certificate_data, certificate_password) do

As per coding guidelines: "Every function (public and private) must have @spec type specification."


45-64: Missing @spec for private function poll_until_ready/3.

Per coding guidelines, every function (public and private) must have a @spec type specification.

🔧 Proposed fix
+  `@spec` poll_until_ready(String.t(), String.t(), non_neg_integer()) :: :ok | {:error, term()}
   defp poll_until_ready(reference_number, auth_token, attempt \\ 0)

As per coding guidelines: "Every function (public and private) must have @spec type specification."

…t found

- SyncWorker now returns {:cancel, reason} instead of silently returning
  :ok when credential or certificate is missing. This makes the failure
  visible in oban_jobs state and logs.
- XadesSigner.Xmlsec1 now catches :enoent from System.cmd and returns a
  clear error message instead of crashing with a raw EXIT.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/ksef_hub/xades_signer/xmlsec1.ex (1)

15-16: 🛠️ Refactor suggestion | 🟠 Major

Missing @doc and @spec for public function.

The sign_challenge/4 function is a public callback but lacks the required documentation and type specification.

Proposed fix
+  `@doc` """
+  Signs an authentication challenge using xmlsec1 CLI with the provided certificate.
+  Returns the signed XML on success or an error tuple on failure.
+  """
+  `@spec` sign_challenge(String.t(), String.t(), binary(), String.t()) ::
+          {:ok, String.t()} | {:error, term()}
   `@impl` true
   def sign_challenge(challenge, nip, certificate_data, certificate_password) do

As per coding guidelines: "Every public function must have @doc documentation" and "Every function (public and private) must have @spec type specification".

🧹 Nitpick comments (1)
lib/ksef_hub/xades_signer/xmlsec1.ex (1)

57-59: Misleading exit code 0 in error tuple.

Using 0 as the exit code suggests success, but this branch handles a failure case. Consider using a distinct sentinel value or omitting the exit code field entirely for this error type.

Proposed fix
         {:ok, {:error, %ErlangError{original: reason}}} ->
           Logger.error("xmlsec1 failed to start: #{inspect(reason)}")
-          {:error, {:xmlsec1_failed, 0, inspect(reason)}}
+          {:error, {:xmlsec1_start_failed, inspect(reason)}}

xmlsec1 1.3+ (xmlsec library 3.x, installed via Homebrew) removed the
--pwd-file flag. Detect version at runtime and use --pwd for >= 1.3,
--pwd-file for older versions (Linux/Docker).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/ksef_hub/xades_signer/xmlsec1.ex (1)

17-73: 🛠️ Refactor suggestion | 🟠 Major

Missing @doc and function exceeds complexity threshold.

  1. Per coding guidelines, every public function must have @doc documentation.
  2. The pipeline correctly flags cyclomatic complexity at 10 (max 9). The function handles multiple concerns: temp file setup, task execution, result pattern matching, and error handling.

Consider extracting the task execution and result handling:

♻️ Proposed refactor to reduce complexity
+  `@doc` """
+  Signs the authentication challenge using xmlsec1 CLI with the provided PKCS12 certificate.
+
+  Returns `{:ok, signed_xml}` on success, or `{:error, reason}` on failure.
+  """
   `@spec` sign_challenge(String.t(), String.t(), binary(), String.t()) ::
           {:ok, String.t()} | {:error, term()}
   def sign_challenge(challenge, nip, certificate_data, certificate_password) do
     xml_template = build_auth_token_request(challenge, nip)

     cert_path = SecureTemp.write(certificate_data, "cert.p12")
     xml_path = SecureTemp.write(xml_template, "request.xml")
     signed_path = SecureTemp.path("signed.xml")

     {password_args, password_path} = build_password_args(certificate_password)

     try do
       args =
         ["--sign", "--pkcs12", cert_path] ++
           password_args ++
           ["--output", signed_path, xml_path]

-      task =
-        Task.async(fn ->
-          try do
-            System.cmd("xmlsec1", args, stderr_to_stdout: true)
-          rescue
-            e in ErlangError ->
-              {:error, e}
-          end
-        end)
-
-      case Task.yield(task, 30_000) || Task.shutdown(task, :brutal_kill) do
-        {:ok, {_output, 0}} ->
-          {:ok, File.read!(signed_path)}
-
-        {:ok, {:error, %ErlangError{original: :enoent}}} ->
-          Logger.error(
-            "xmlsec1 not found. Install it: brew install xmlsec1 (macOS) or apt-get install xmlsec1 (Linux)"
-          )
-
-          {:error, {:xmlsec1_not_found, "xmlsec1 binary not found in PATH"}}
-
-        {:ok, {:error, %ErlangError{original: reason}}} ->
-          Logger.error("xmlsec1 failed to start: #{inspect(reason)}")
-          {:error, {:xmlsec1_failed, 0, inspect(reason)}}
-
-        {:ok, {output, exit_code}} ->
-          Logger.error("xmlsec1 failed (exit #{exit_code}): #{output}")
-          {:error, {:xmlsec1_failed, exit_code, output}}
-
-        nil ->
-          Logger.error("xmlsec1 timed out after 30s")
-          {:error, :timeout}
-      end
+      args
+      |> run_xmlsec1_with_timeout()
+      |> handle_xmlsec1_result(signed_path)
     after
       cleanup_paths = [cert_path, xml_path, signed_path]
       cleanup_paths = if password_path, do: [password_path | cleanup_paths], else: cleanup_paths
       Enum.each(cleanup_paths, &SecureTemp.delete/1)
     end
   end
+
+  `@spec` run_xmlsec1_with_timeout([String.t()]) :: {:ok, term()} | nil
+  defp run_xmlsec1_with_timeout(args) do
+    task =
+      Task.async(fn ->
+        try do
+          System.cmd("xmlsec1", args, stderr_to_stdout: true)
+        rescue
+          e in ErlangError -> {:error, e}
+        end
+      end)
+
+    Task.yield(task, 30_000) || Task.shutdown(task, :brutal_kill)
+  end
+
+  `@spec` handle_xmlsec1_result({:ok, term()} | nil, String.t()) ::
+          {:ok, String.t()} | {:error, term()}
+  defp handle_xmlsec1_result({:ok, {_output, 0}}, signed_path), do: {:ok, File.read!(signed_path)}
+
+  defp handle_xmlsec1_result({:ok, {:error, %ErlangError{original: :enoent}}}, _signed_path) do
+    Logger.error("xmlsec1 not found. Install it: brew install xmlsec1 (macOS) or apt-get install xmlsec1 (Linux)")
+    {:error, {:xmlsec1_not_found, "xmlsec1 binary not found in PATH"}}
+  end
+
+  defp handle_xmlsec1_result({:ok, {:error, %ErlangError{original: reason}}}, _signed_path) do
+    Logger.error("xmlsec1 failed to start: #{inspect(reason)}")
+    {:error, {:xmlsec1_failed, 0, inspect(reason)}}
+  end
+
+  defp handle_xmlsec1_result({:ok, {output, exit_code}}, _signed_path) do
+    Logger.error("xmlsec1 failed (exit #{exit_code}): #{output}")
+    {:error, {:xmlsec1_failed, exit_code, output}}
+  end
+
+  defp handle_xmlsec1_result(nil, _signed_path) do
+    Logger.error("xmlsec1 timed out after 30s")
+    {:error, :timeout}
+  end
🤖 Fix all issues with AI agents
In `@lib/ksef_hub/xades_signer/xmlsec1.ex`:
- Around line 75-85: The build_password_args/1 function currently passes the
password on the command line when supports_pwd_file?() is false, exposing
secrets; update this by documenting the risk in the module moduledoc, change
build_password_args/1 to prefer writing password to a temp file with secure
permissions via SecureTemp.write (and return the file path) when possible, and
add a fallback strategy: detect if the installed xmlsec1 supports stdin password
(e.g., `--pwd -`) and use stdin-based password passing instead of `--pwd` when
available; also add guidance in the moduledoc recommending pinning xmlsec1 < 1.3
in controlled images and instruct operators to ensure temp files are created
with 0600 permissions and securely removed.
🧹 Nitpick comments (1)
lib/ksef_hub/xades_signer/xmlsec1.ex (1)

87-104: Consider caching version detection and documenting fallback behavior.

Two observations:

  1. Repeated syscalls: supports_pwd_file?/0 spawns xmlsec1 --version on every signing operation. Since the installed version won't change at runtime, consider memoizing the result (e.g., via :persistent_term or module attribute at compile time if acceptable).

  2. Fallback defaults to insecure: When version parsing fails (lines 95-96, 99-100, 102-103), the function returns false, causing --pwd usage. This is the less secure path. Consider logging a warning when falling back so operators are aware.

♻️ Optional: Add warning on fallback
         [_, major, minor] ->
           {String.to_integer(major), String.to_integer(minor)} < {1, 3}

         _ ->
+          Logger.warning("Could not parse xmlsec1 version, falling back to --pwd")
           false
       end

     _ ->
+      Logger.warning("xmlsec1 --version failed, falling back to --pwd")
       false
   end
 rescue
-  _ -> false
+  _ ->
+    Logger.warning("xmlsec1 version check raised, falling back to --pwd")
+    false
 end

Comment on lines +75 to +85
# xmlsec1 >= 1.3 (xmlsec library 3.x) removed --pwd-file, only --pwd is available.
# Older versions support --pwd-file for secure file-based password passing.
@spec build_password_args(String.t()) :: {[String.t()], String.t() | nil}
defp build_password_args(password) do
if supports_pwd_file?() do
path = SecureTemp.write(password, "password.txt")
{["--pwd-file", path], path}
else
{["--pwd", password], nil}
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Security concern: password passed as CLI argument for xmlsec1 >= 1.3.

Line 83 passes the certificate password directly via --pwd, which exposes it in process listings (ps aux), /proc/<pid>/cmdline, and potentially system audit logs. This violates the security guidance to "never pass passwords as CLI arguments."

I understand xmlsec1 >= 1.3 removed --pwd-file, but consider:

  1. Document the risk in the moduledoc so operators can make informed deployment decisions.
  2. Pin xmlsec1 < 1.3 in Docker images where you control the environment.
  3. Explore stdin passing — some xmlsec1 builds accept password via stdin with --pwd - (verify with your target version).
📝 Suggested documentation update
   `@moduledoc` """
   Production XADES signer using xmlsec1 CLI.
   Only called during initial auth and re-auth (~every 48 days).
   Uses secure temp files with 0600 permissions, cleaned up after use.

   Supports both xmlsec1 < 1.3 (has --pwd-file) and >= 1.3 (only --pwd).
+
+  ## Security Note
+
+  xmlsec1 >= 1.3 removed the `--pwd-file` option, requiring passwords to be
+  passed via `--pwd` CLI argument. This exposes the password in process listings.
+  For production deployments, consider using xmlsec1 < 1.3 or ensuring the
+  environment restricts access to process information.
   """

Based on learnings: "When calling xmlsec1 for XADES signing: write cert/password to temp files with 0600 permissions, never pass passwords as CLI arguments."

🤖 Prompt for AI Agents
In `@lib/ksef_hub/xades_signer/xmlsec1.ex` around lines 75 - 85, The
build_password_args/1 function currently passes the password on the command line
when supports_pwd_file?() is false, exposing secrets; update this by documenting
the risk in the module moduledoc, change build_password_args/1 to prefer writing
password to a temp file with secure permissions via SecureTemp.write (and return
the file path) when possible, and add a fallback strategy: detect if the
installed xmlsec1 supports stdin password (e.g., `--pwd -`) and use stdin-based
password passing instead of `--pwd` when available; also add guidance in the
moduledoc recommending pinning xmlsec1 < 1.3 in controlled images and instruct
operators to ensure temp files are created with 0600 permissions and securely
removed.

emilwojtaszek and others added 2 commits February 11, 2026 15:51
xmlsec1 requires a <ds:Signature> skeleton in the XML template to know
where to place the signature. Without it, signing fails with
"failed to find default node with name=Signature".

Also reverts temporary debug logging from xmlsec1.ex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract run_xmlsec1/1 and handle_result/2 helpers to satisfy
credo --strict complexity threshold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek merged commit ffca607 into main Feb 11, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant