Skip to content

fix: SyncWorker auto-reauthenticates instead of cancelling on expired…#40

Merged
emilwojtaszek merged 4 commits into
mainfrom
fix/sync-worker-auto-reauth
Feb 15, 2026
Merged

fix: SyncWorker auto-reauthenticates instead of cancelling on expired…#40
emilwojtaszek merged 4 commits into
mainfrom
fix/sync-worker-auto-reauth

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Feb 15, 2026

Copy link
Copy Markdown
Member

… tokens

SyncWorker permanently cancelled ({:cancel, :reauth_required}) when TokenManager had no valid access token, stopping syncs entirely until the next manual certificate upload.

Extract shared XADES auth logic into KsefClient.Authenticator so both AuthWorker and SyncWorker can perform authentication. SyncWorker now attempts re-auth on :reauth_required and returns retryable errors instead of permanent cancellations.

  • Add KsefClient.Authenticator with authenticate_and_store/1
  • Refactor AuthWorker to delegate to Authenticator
  • SyncWorker.get_access_token attempts reauth via Authenticator
  • Permanent errors (no_credential, no_certificate) still cancel
  • Transient errors (KSeF API failures) are retried by Oban
  • TokenManager.store_tokens return value is now checked

Summary by CodeRabbit

  • New Features

    • Automatic re-authentication during sync when tokens expire; failures mark retryable or permanent and record reauth metadata.
    • Sessions are now terminated safely after successful syncs.
  • Refactor

    • Centralized authentication into a dedicated authenticator and simplified re-auth orchestration with clearer success logging.
  • Tests

    • Added comprehensive tests for re-auth, expired-token flows, token storage, and multiple failure cases.

… tokens

SyncWorker permanently cancelled ({:cancel, :reauth_required}) when
TokenManager had no valid access token, stopping syncs entirely until
the next manual certificate upload.

Extract shared XADES auth logic into KsefClient.Authenticator so both
AuthWorker and SyncWorker can perform authentication. SyncWorker now
attempts re-auth on :reauth_required and returns retryable errors
instead of permanent cancellations.

- Add KsefClient.Authenticator with authenticate_and_store/1
- Refactor AuthWorker to delegate to Authenticator
- SyncWorker.get_access_token attempts reauth via Authenticator
- Permanent errors (no_credential, no_certificate) still cancel
- Transient errors (KSeF API failures) are retried by Oban
- TokenManager.store_tokens return value is now checked

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

coderabbitai Bot commented Feb 15, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Extracts XAdES authentication into a new KsefHub.KsefClient.Authenticator, updates AuthWorker to delegate to it, expands SyncWorker re-auth orchestration and session termination, and adds tests covering Authenticator and re-auth scenarios.

Changes

Cohort / File(s) Summary
Authenticator module
lib/ksef_hub/ksef_client/authenticator.ex
New module Authenticator.authenticate_and_store/1 encapsulates credential+certificate loading, decryption/normalization, XAdES challenge/sign/auth/redeem flow, and token storage; returns {:ok, token} or {:error, reason}.
Auth worker
lib/ksef_hub/ksef_client/auth_worker.ex
Replaced inline auth steps with a single call to Authenticator.authenticate_and_store/1; removed private helpers and decryption logic; simplified aliases and added success logging.
Sync worker & orchestration
lib/ksef_hub/sync/sync_worker.ex
Centralized re-auth via attempt_reauth/1 using Authenticator, introduced @permanent_reauth_errors, updated error/metadata handling, and added terminate_session_safely/2 and ksef_client/0; terminates session only after successful sync.
Tests
test/ksef_hub/ksef_client/authenticator_test.exs, test/ksef_hub/sync/sync_worker_test.exs
Adds comprehensive tests for Authenticator success and failure paths and expanded SyncWorker re-auth scenarios using Mox, TokenManager assertions, and seeded encrypted credentials/certificates.
Manifest
mix.exs
Minor manifest changes to account for added module/tests (few lines changed).

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Worker as AuthWorker
participant Auth as Authenticator
participant Creds as Credentials Store
participant Certs as Certificates Store
participant KSeF as KSeF (external)
participant Tokens as TokenManager

Worker->>Auth: authenticate_and_store(company_id)
Auth->>Creds: load_credential(company_id)
Creds-->>Auth: {:ok, credential} / {:error, :no_credential}
Auth->>Certs: load_certificate(company_id)
Certs-->>Auth: {:ok, certificate} / {:error, :no_certificate}
Auth->>KSeF: get_challenge(signing_key)
KSeF-->>Auth: challenge
Auth->>KSeF: sign_challenge & authenticate_xades
KSeF-->>Auth: auth_result / error
Auth->>KSeF: redeem_tokens
KSeF-->>Auth: access_token, refresh_token
Auth->>Tokens: store(company_id, access_token, refresh_token)
Tokens-->>Auth: :ok
Auth-->>Worker: {:ok, access_token} / {:error, reason}

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hopped through keys and certs tonight,
Pulled auth from work and set it right,
One call now dances — challenge, sign, store,
Tokens tucked safe, tests nibble for more,
A carrot for builds — hop, code, delight! 🌿

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (12 files):

⚔️ lib/ksef_hub/invoices/invoice.ex (content)
⚔️ lib/ksef_hub/ksef_client/auth_worker.ex (content)
⚔️ lib/ksef_hub/sync/sync_worker.ex (content)
⚔️ lib/ksef_hub_web/controllers/api/invoice_controller.ex (content)
⚔️ lib/ksef_hub_web/controllers/invoice_pdf_controller.ex (content)
⚔️ lib/ksef_hub_web/helpers/filename_helpers.ex (content)
⚔️ lib/ksef_hub_web/live/invoice_live/show.ex (content)
⚔️ lib/ksef_hub_web/router.ex (content)
⚔️ test/ksef_hub/sync/sync_worker_test.exs (content)
⚔️ test/ksef_hub_web/controllers/api/invoice_controller_test.exs (content)
⚔️ test/ksef_hub_web/live/invoice_live/show_test.exs (content)
⚔️ test/support/factory.ex (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: SyncWorker now auto-reauthenticates on expired tokens instead of canceling the job, which is the core objective of this refactoring.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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 fix/sync-worker-auto-reauth
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch fix/sync-worker-auto-reauth
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

No actionable comments were generated in the recent review. 🎉


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

Replace case expressions in load_credential/1 and load_certificate/1
with pipeline + function-head pattern matching via normalize_* helpers.

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

🤖 Fix all issues with AI agents
In `@lib/ksef_hub/ksef_client/authenticator.ex`:
- Around line 16-38: In authenticate_and_store/1, after successfully calling
TokenManager.store_tokens (and before returning {:ok, tokens.access_token}),
call terminate_session(tokens.access_token) to explicitly end the KSeF session;
invoke the existing terminate_session/1 function (or add it if missing), and
handle its result locally (log errors but do not change the overall success
return) so session termination won’t break the happy path; apply the same
pattern to any other authentication flows in this module that redeem tokens
(i.e., after Auth.authenticate and TokenManager.store_tokens).

Comment on lines +16 to +38
@doc """
Loads the certificate for a company, decrypts it, performs full XADES
authentication against KSeF, and stores the resulting tokens.

Returns `{:ok, access_token}` on success, or `{:error, reason}` on failure.
"""
@spec authenticate_and_store(Ecto.UUID.t()) :: {:ok, String.t()} | {:error, term()}
def authenticate_and_store(company_id) do
with {:ok, credential} <- load_credential(company_id),
{:ok, user_cert} <- load_certificate(company_id),
{:ok, cert_data} <- Encryption.decrypt(user_cert.certificate_data_encrypted),
{:ok, password} <- Encryption.decrypt(user_cert.certificate_password_encrypted),
{:ok, tokens} <- Auth.authenticate(credential.nip, cert_data, password),
:ok <-
TokenManager.store_tokens(
company_id,
tokens.access_token,
tokens.refresh_token,
tokens.access_valid_until,
tokens.refresh_valid_until
) do
Logger.info("Authenticator: XADES authentication successful for company #{company_id}")
{:ok, tokens.access_token}

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

🧩 Analysis chain

🏁 Script executed:

find lib/ksef_hub/ksef_client -name "*.ex" | head -20

Repository: appunite/appunite-ksef-ex

Length of output: 296


🏁 Script executed:

# Search for Auth.authenticate implementation
rg -n --type elixir "def authenticate" lib/ksef_hub/ksef_client/

Repository: appunite/appunite-ksef-ex

Length of output: 336


🏁 Script executed:

# Search for session termination patterns
rg -n --type elixir "(terminate|logout|close_session|end_session|session)" lib/ksef_hub/ksef_client/ -i

Repository: appunite/appunite-ksef-ex

Length of output: 338


🏁 Script executed:

# Read the Auth.authenticate implementation
cat -n lib/ksef_hub/ksef_client/auth.ex | head -60

Repository: appunite/appunite-ksef-ex

Length of output: 2564


🏁 Script executed:

# Read the entire authenticator.ex to see full function and if terminate_session is used
cat -n lib/ksef_hub/ksef_client/authenticator.ex

Repository: appunite/appunite-ksef-ex

Length of output: 3194


🏁 Script executed:

# Check the terminate_session callback and implementation
cat -n lib/ksef_hub/ksef_client/behaviour.ex | sed -n '40,50p'

Repository: appunite/appunite-ksef-ex

Length of output: 371


🏁 Script executed:

# Check terminate_session in live.ex
cat -n lib/ksef_hub/ksef_client/live.ex | sed -n '240,260p'

Repository: appunite/appunite-ksef-ex

Length of output: 803


Explicitly terminate the KSeF session after successful authentication.

The authenticate_and_store/1 function does not terminate the KSeF session after tokens are redeemed. Add a call to terminate_session(tokens.access_token) in the success path (after TokenManager.store_tokens succeeds but before returning) to prevent session leakage. This applies to all authentication flows in this module.

🤖 Prompt for AI Agents
In `@lib/ksef_hub/ksef_client/authenticator.ex` around lines 16 - 38, In
authenticate_and_store/1, after successfully calling TokenManager.store_tokens
(and before returning {:ok, tokens.access_token}), call
terminate_session(tokens.access_token) to explicitly end the KSeF session;
invoke the existing terminate_session/1 function (or add it if missing), and
handle its result locally (log errors but do not change the overall success
return) so session termination won’t break the happy path; apply the same
pattern to any other authentication flows in this module that redeem tokens
(i.e., after Auth.authenticate and TokenManager.store_tokens).

Prevents session leakage by explicitly calling terminate_session after
sync finishes (success or partial). On sync failure, the session is kept
alive so Oban retries can reuse the token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek force-pushed the fix/sync-worker-auto-reauth branch from 89591ce to e09a2f1 Compare February 15, 2026 17:36

@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: 4

🤖 Fix all issues with AI agents
In `@lib/ksef_hub/sync/sync_worker.ex`:
- Around line 15-16: Add a `@doc` above the public perform/1 function in module
KsefHub.Sync.SyncWorker (the def perform(%Oban.Job{args: %{"company_id" =>
company_id}} = job) definition) describing what the job does, the expected args
(company_id), and the return value/behaviour (success/failure tuple), placing
the `@doc` immediately above the existing `@impl` Oban.Worker line so the public
function is documented per guidelines.
- Around line 20-28: The code currently calls
terminate_session_safely(access_token, company_id) before
handle_sync_result(sync_result, credential), which can terminate the session
even if handle_sync_result (and downstream update_last_sync/1) fails; move the
session termination to after handle_sync_result returns successfully and only
terminate when the final result is :ok (e.g., call
handle_sync_result(sync_result, credential) first, check that it returned :ok,
and then call terminate_session_safely(access_token, company_id)); ensure you
still preserve the original behavior of keeping the session alive on any error
by pattern matching on the final outcome rather than on sync_all_types alone.

In `@test/ksef_hub/sync/sync_worker_test.exs`:
- Around line 111-155: The test block's formatting is out of sync with mix
format; run mix format and fix whitespace/indentation in the
test/ksef_hub/sync/sync_worker_test.exs block that sets expectations for
KsefHub.KsefClient.Mock and KsefHub.XadesSigner.Mock (the sequence around
expect(:redeem_tokens, ...), expect(:query_invoice_metadata, ...),
expect(:terminate_session, ...) and the final assert calling
SyncWorker.perform/1). Reformat the multiline map and function clauses to match
project formatting (use mix format) so the CI formatting check passes.
- Around line 198-242: Reformat the failing test block with mix format: run `mix
format` (or your editor's Elixir formatter) on
test/ksef_hub/sync/sync_worker_test.exs to fix the formatting around the mocked
expectations (the chain of KsefHub.KsefClient.Mock |> expect... |> expect...)
and the assert call to SyncWorker.perform; ensure indentation, pipe alignment
and spacing match mix format rules so CI formatting check passes.

Comment thread lib/ksef_hub/sync/sync_worker.ex
Comment thread lib/ksef_hub/sync/sync_worker.ex Outdated
Comment on lines +111 to +155
# Expect XADES re-authentication flow
KsefHub.KsefClient.Mock
|> expect(:get_challenge, fn ->
{:ok, %{challenge: "reauth-challenge", timestamp: "2025-01-15T12:00:00Z"}}
end)

KsefHub.XadesSigner.Mock
|> expect(:sign_challenge, fn "reauth-challenge", "1234567890", "cert-data", "cert-pass" ->
{:ok, "<ReauthSignedXML/>"}
end)

KsefHub.KsefClient.Mock
|> expect(:authenticate_xades, fn "<ReauthSignedXML/>" ->
{:ok,
%{
reference_number: "ref-reauth",
auth_token: "auth-tok-reauth",
auth_token_valid_until: DateTime.add(DateTime.utc_now(), 300)
}}
end)

KsefHub.KsefClient.Mock
|> expect(:poll_auth_status, fn "ref-reauth", "auth-tok-reauth" ->
{:ok, :success}
end)

KsefHub.KsefClient.Mock
|> expect(:redeem_tokens, fn "auth-tok-reauth" ->
{:ok,
%{
access_token: "reauth-access",
refresh_token: "reauth-refresh",
access_valid_until: access_until,
refresh_valid_until: refresh_until
}}
end)
# After re-auth, expect sync queries with the new token
KsefHub.KsefClient.Mock
|> expect(:query_invoice_metadata, 2, fn "reauth-access", _filters, _opts ->
{:ok, %{invoices: [], has_more: false, is_truncated: false}}
end)
|> expect(:terminate_session, fn "reauth-access" -> :ok end)

assert :ok = SyncWorker.perform(%Oban.Job{args: %{"company_id" => company.id}})
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 | 🟡 Minor

Run mix format — formatting check failed in this block.

CI reports a mix format failure touching this region (Lines 145-148). Please reformat the file.

🧰 Tools
🪛 GitHub Actions: CI

[error] 145-148: mix format check failed. The following files are not formatted. Run 'mix format' to fix. (Lines 145-148 show formatting changes required.)

🤖 Prompt for AI Agents
In `@test/ksef_hub/sync/sync_worker_test.exs` around lines 111 - 155, The test
block's formatting is out of sync with mix format; run mix format and fix
whitespace/indentation in the test/ksef_hub/sync/sync_worker_test.exs block that
sets expectations for KsefHub.KsefClient.Mock and KsefHub.XadesSigner.Mock (the
sequence around expect(:redeem_tokens, ...), expect(:query_invoice_metadata,
...), expect(:terminate_session, ...) and the final assert calling
SyncWorker.perform/1). Reformat the multiline map and function clauses to match
project formatting (use mix format) so the CI formatting check passes.

Comment on lines +198 to +242
# Expect XADES re-authentication flow (triggered by expired tokens)
KsefHub.KsefClient.Mock
|> expect(:get_challenge, fn ->
{:ok, %{challenge: "expired-challenge", timestamp: "2025-01-15T12:00:00Z"}}
end)

KsefHub.XadesSigner.Mock
|> expect(:sign_challenge, fn "expired-challenge", "1234567890", "cert-data", "cert-pass" ->
{:ok, "<ExpiredSignedXML/>"}
end)

KsefHub.KsefClient.Mock
|> expect(:authenticate_xades, fn "<ExpiredSignedXML/>" ->
{:ok,
%{
reference_number: "ref-expired",
auth_token: "auth-tok-expired",
auth_token_valid_until: DateTime.add(DateTime.utc_now(), 300)
}}
end)

KsefHub.KsefClient.Mock
|> expect(:poll_auth_status, fn "ref-expired", "auth-tok-expired" ->
{:ok, :success}
end)

KsefHub.KsefClient.Mock
|> expect(:redeem_tokens, fn "auth-tok-expired" ->
{:ok,
%{
access_token: "fresh-access",
refresh_token: "fresh-refresh",
access_valid_until: access_until,
refresh_valid_until: refresh_until
}}
end)
# After re-auth, expect sync queries with the fresh token
KsefHub.KsefClient.Mock
|> expect(:query_invoice_metadata, 2, fn "fresh-access", _filters, _opts ->
{:ok, %{invoices: [], has_more: false, is_truncated: false}}
end)
|> expect(:terminate_session, fn "fresh-access" -> :ok end)

assert :ok = SyncWorker.perform(%Oban.Job{args: %{"company_id" => company.id}})
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 | 🟡 Minor

Run mix format — formatting check failed in this block.

CI reports a mix format failure touching this region (Lines 232-235). Please reformat the file.

🧰 Tools
🪛 GitHub Actions: CI

[error] 232-235: mix format check failed. The following files are not formatted. Run 'mix format' to fix. (Lines 232-235 show formatting changes required.)

🤖 Prompt for AI Agents
In `@test/ksef_hub/sync/sync_worker_test.exs` around lines 198 - 242, Reformat the
failing test block with mix format: run `mix format` (or your editor's Elixir
formatter) on test/ksef_hub/sync/sync_worker_test.exs to fix the formatting
around the mocked expectations (the chain of KsefHub.KsefClient.Mock |>
expect... |> expect...) and the assert call to SyncWorker.perform; ensure
indentation, pipe alignment and spacing match mix format rules so CI formatting
check passes.

Move terminate_session_safely after handle_sync_result so the KSeF
session is only terminated when the entire workflow (including
update_last_sync) succeeds. This keeps the session alive for Oban
retries on any failure path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek merged commit 513fc6a into main Feb 15, 2026
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