Skip to content

feat(authenticator): OIDC login + sessions + cookie-to-JWT exchange (nginx+auth step 04)#1686

Merged
cyberantonz merged 12 commits into
constructorfabric:mainfrom
cyberantonz:nginx-authenticator-04
Jul 9, 2026
Merged

feat(authenticator): OIDC login + sessions + cookie-to-JWT exchange (nginx+auth step 04)#1686
cyberantonz merged 12 commits into
constructorfabric:mainfrom
cyberantonz:nginx-authenticator-04

Conversation

@cyberantonz

@cyberantonz cyberantonz commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

nginx + authorization — Step 04: authenticator gear skeleton

Part of the EPIC #1583 (replace the Rust API-gateway Router with nginx auth_request + a standalone authenticator). Closes the step-04 subtask #1587.

Implements the BFF / token-handler service per NGINX_BFF.md §4.1/§11 and the step-01 authenticator PRD/DESIGN: OIDC code+PKCE login, opaque Redis sessions, and the cookie→JWT exchange the nginx gateway will call.

Stacks on the step-03 fakeidp (already merged, 6b993ebd) — the e2e loop drives that fake IdP.

What's here

Crates

  • services/authenticator/ — the #[toolkit::gear(name = "authenticator", deps = ["types-registry"], capabilities = [rest, stateful])] gear (package authenticator), on the api-gateway REST host like analytics.
  • libs/authenticator-sdk/AuthenticatorClientV1 trait (revoke_user_sessions) + DTOs + typed error; registered in the ClientHub. Consumers depend on the SDK only.

Behaviour

  • Config AuthenticatorConfig (serde(default, deny_unknown_fields)) — the §4.1 table 1:1 (session/JWT TTLs, jitter, idp.*, roles, bootstrap, csrf, redis/keys/identity URLs).
  • Redis store (asm: schema, per DESIGN §3.7 — the identity/credential split): asm:token, asm:session HASH, asm:jwt, asm:user_sessions ZSET, asm:sid_index, asm:login_state, asm:idp_refresh_due. All multi-key writes in MULTI/EXEC. Fails closed (Redis down → 401/503, boot pings Redis).
  • JWT issuer + Key Store + JWKS — ES256 (see §9.6 below), keys from a mounted PKCS#8 dir (current.pem + optional previous.pem), kid = RFC 7638 thumbprint.
  • OIDC client — discovery, authorize+PKCE(S256)+nonce, code exchange, id_token validation (sig via JWKS, iss/aud/nonce/exp), RP-logout URL.
  • Endpoints via OperationBuilder, all .public() (cookie-authenticated in handler): GET /auth/login, GET /auth/callback (session-fixation guard, person resolve, audited first-admin bootstrap, session+linked-JWT in one pipeline, __Host-sid cookie, sanitized return_to), GET /internal/authz (exchange + reissue-ahead SET NX EX, X-Gateway-Jwt + computed Cache-Control), GET /.well-known/jwks.json, GET /auth/me, POST /auth/logout. Errors as CanonicalError.

§9.6 decision — ES256. Downstream oidc-authn-plugin validates RS256/ES256 today; EdDSA would need a non-trivial plugin change, and downstream verification is a later phase. ES256 meets DD-BFF-05's rationale (small sig, fast verify) with zero downstream friction. Recorded in the authenticator DESIGN (RESOLVED (step 04)), cfs toc+validate clean.

Wiringconfig/insight.yaml, multi-stage Dockerfile, docker-compose authenticator service (:8083, depends redis+fakeidp) with dev ES256 keys, helm/insight-authenticator chart (deployment + configmap + service + signing-keys Secret + probes), build-images.yml path filter + multi-arch build/merge jobs.

Tests

  • 15 unit tests: cookie Set-Cookie snapshot, fixation-safe cookie parsing, Cache-Control (60 s travel margin / no-store / disabled), sign↔verify claim contents, JWKS shape, stable kid, sanitize_return_to, jwt_exp.
  • e2e integration test (tests/e2e_login_loop.rs, #[ignore]) + tests/run-e2e.sh runner. Proven green locally against fakeidp: login → /authorize → callback (cookie) → /internal/authz (JWT verified against the published JWKS) → /auth/me → /auth/logout → /internal/authz = 401 no-store. The bootstrap-admin audit line fires and the session is revoked on logout.

cargo test green; cargo clippy clean (pedantic + unwrap/expect denied).

Deliberate scope / follow-ups

  • Toolkit pins bumped to gears-rust HEAD (toolkit 0.6.13, auth/security 0.7.2, api-gateway 0.2.9, …); existing analytics still compiles.
  • Person resolution / bootstrap: Identity's GET /v1/persons/{email} returns a person id but no tenant memberships and there is no count/create API, so — per the brief — it sits behind a PersonResolver trait: person_id from Identity, tenants from the id_token, first-admin bootstrap gated on the config flag (loudly audited). Follow-up: INSTALLER + a real Identity membership/emptiness API (to be filed).
  • Out of scope (later steps): /auth/refresh rotation, /auth/sessions, back-channel logout, CSRF enforcement, IdP background refresher + janitor (the stateful runnable is a returning no-op stub), /internal/token second listener, the nginx gateway itself. Umbrella subchart integration (descriptor + Chart.lock) lands with the deploy step.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a new authenticator backend service with OIDC login/callback, session summary, logout, gateway JWT issuance, and public JWKS endpoints.
    • Added Docker Compose and Helm support to run and deploy the authenticator (including Redis + signing key setup) alongside the existing stack.
    • CI now builds and publishes authenticator images.
  • Bug Fixes
    • Updated default in-network issuer and back-channel logout settings to make OIDC flows work reliably in containers.
    • Hardened session-cookie behavior and improved authz failure-closed/caching responses.
  • Tests
    • Added an end-to-end login-loop smoke test and a local identity stub for development.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@cyberantonz, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b0bf15c-4ba8-48f5-bd4f-288b7b19aeb1

📥 Commits

Reviewing files that changed from the base of the PR and between fa2f4e5 and d8a2cc8.

⛔ Files ignored due to path filters (2)
  • src/backend/Cargo.lock is excluded by !**/*.lock
  • testdisk.log is excluded by !**/*.log
📒 Files selected for processing (37)
  • .env.compose.example
  • .github/workflows/build-images.yml
  • .gitignore
  • dev-compose.sh
  • docker-compose.yml
  • docs/components/backend/authenticator/DESIGN.md
  • scripts/ci/components.py
  • src/backend/Cargo.toml
  • src/backend/libs/authenticator-sdk/Cargo.toml
  • src/backend/libs/authenticator-sdk/src/lib.rs
  • src/backend/services/analytics/Dockerfile
  • src/backend/services/api-gateway/Dockerfile
  • src/backend/services/authenticator/Cargo.toml
  • src/backend/services/authenticator/Dockerfile
  • src/backend/services/authenticator/config/insight.yaml
  • src/backend/services/authenticator/helm/Chart.yaml
  • src/backend/services/authenticator/helm/templates/_helpers.tpl
  • src/backend/services/authenticator/helm/templates/configmap.yaml
  • src/backend/services/authenticator/helm/templates/deployment.yaml
  • src/backend/services/authenticator/helm/templates/service.yaml
  • src/backend/services/authenticator/helm/values.yaml
  • src/backend/services/authenticator/src/api/error.rs
  • src/backend/services/authenticator/src/api/handlers.rs
  • src/backend/services/authenticator/src/api/mod.rs
  • src/backend/services/authenticator/src/config.rs
  • src/backend/services/authenticator/src/cookie.rs
  • src/backend/services/authenticator/src/gear.rs
  • src/backend/services/authenticator/src/identity.rs
  • src/backend/services/authenticator/src/jwt.rs
  • src/backend/services/authenticator/src/local_client.rs
  • src/backend/services/authenticator/src/main.rs
  • src/backend/services/authenticator/src/oidc.rs
  • src/backend/services/authenticator/src/session.rs
  • src/backend/services/authenticator/tests/e2e_login_loop.rs
  • src/backend/services/authenticator/tests/identity-stub.py
  • src/backend/services/authenticator/tests/run-e2e.sh
  • src/backend/services/fakeidp/Dockerfile
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cyberantonz
cyberantonz force-pushed the nginx-authenticator-04 branch from 00078d6 to b19032a Compare July 8, 2026 13:49

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

🧹 Nitpick comments (3)
src/backend/services/authenticator/Dockerfile (1)

66-68: 🔒 Security & Privacy | 🔵 Trivial | ⚖️ Poor tradeoff

Verify watchexec download integrity with a checksum.

The watchexec binary is downloaded from GitHub releases over HTTPS but without SHA256 verification. A compromised release or CDN cache poisoning would inject a malicious binary into the production image. Since the binary persists in the runtime image (even though only used in compose), adding checksum verification closes the supply-chain gap.

🔒️ Suggested checksum verification
 ARG WATCHEXEC_VERSION=2.3.2
+ARG WATCHEXEC_SHA256_AMD64=<amd64-sha256>
+ARG WATCHEXEC_SHA256_ARM64=<arm64-sha256>
 
 RUN set -eux; \
     case "${TARGETARCH:-amd64}" in \
-      amd64) WX_ARCH=x86_64-unknown-linux-musl ;; \
-      arm64) WX_ARCH=aarch64-unknown-linux-musl ;; \
+      amd64) WX_ARCH=x86_64-unknown-linux-musl; WX_SHA256="${WATCHEXEC_SHA256_AMD64}" ;; \
+      arm64) WX_ARCH=aarch64-unknown-linux-musl; WX_SHA256="${WATCHEXEC_SHA256_ARM64}" ;; \
       *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
     esac; \
     apt-get update; \
     apt-get install -y --no-install-recommends ca-certificates curl xz-utils; \
-    curl -fsSL "https://github.com/watchexec/watchexec/releases/download/v${WATCHEXEC_VERSION}/watchexec-${WATCHEXEC_VERSION}-${WX_ARCH}.tar.xz" \
-        | tar -xJ -C /tmp; \
+    curl -fsSL -o /tmp/watchexec.tar.xz "https://github.com/watchexec/watchexec/releases/download/v${WATCHEXEC_VERSION}/watchexec-${WATCHEXEC_VERSION}-${WX_ARCH}.tar.xz"; \
+    echo "${WX_SHA256}  /tmp/watchexec.tar.xz" | sha256sum -c -; \
+    tar -xJ -C /tmp -f /tmp/watchexec.tar.xz; \
+    rm -f /tmp/watchexec.tar.xz; \
     install -m 0755 "/tmp/watchexec-${WATCHEXEC_VERSION}-${WX_ARCH}/watchexec" /usr/local/bin/watchexec; \
     rm -rf /tmp/watchexec-*; \
     apt-get purge -y curl xz-utils; \
     apt-get autoremove -y; \
     rm -rf /var/lib/apt/lists/*
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/authenticator/Dockerfile` around lines 66 - 68, The
watchexec download/install step in the Dockerfile does not verify the release
artifact integrity, so update the release fetch flow to validate the archive
against a published SHA256 checksum before extracting it. Add checksum retrieval
and verification alongside the existing curl and tar pipeline in the watchexec
install block, and keep the install step gated on a successful match so the
image build fails on tampering or corruption.
src/backend/services/authenticator/tests/run-e2e.sh (1)

45-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use '' for empty env var assignment to satisfy shellcheck SC1007.

VAR= \ with a trailing space is valid bash but triggers shellcheck. Using VAR='' \ is more explicit and avoids the warning.

✨ Suggested fix
-APP__gears__authenticator__config__identity_url= \
+APP__gears__authenticator__config__identity_url='' \
 APP__gears__authenticator__config__gateway_issuer=http://localhost:8080 \
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/authenticator/tests/run-e2e.sh` around lines 45 - 46,
The environment variable assignment for the authenticator test setup uses an
empty value with a trailing space, which triggers shellcheck SC1007. Update the
assignment in run-e2e.sh to use an explicit empty string for
APP__gears__authenticator__config__identity_url, and leave the adjacent
APP__gears__authenticator__config__gateway_issuer assignment unchanged.

Source: Linters/SAST tools

src/backend/services/authenticator/helm/templates/deployment.yaml (1)

64-65: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider readOnlyRootFilesystem: true and a seccompProfile for defense-in-depth.

The container security context is solid (allowPrivilegeEscalation: false, non-root). Adding readOnlyRootFilesystem: true and a RuntimeDefault seccomp profile further hardens the pod. Verify the app doesn't write to the container filesystem (the ConfigMap sets home_dir: "/app/data" — if the app writes there, a writable emptyDir volume would be needed).

🛡️ Optional hardening
           securityContext:
             allowPrivilegeEscalation: false
+            readOnlyRootFilesystem: true
+            seccompProfile:
+              type: RuntimeDefault
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/authenticator/helm/templates/deployment.yaml` around
lines 64 - 65, The container securityContext in the deployment template should
be hardened further beyond allowPrivilegeEscalation and non-root. Update the
pod/container spec to add readOnlyRootFilesystem and a RuntimeDefault
seccompProfile, and verify the authenticator app does not need writable paths
under the image filesystem. If it writes to the configured home_dir (/app/data),
adjust the deployment to mount a writable volume such as emptyDir for that path
while keeping the root filesystem read-only.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/services/authenticator/helm/templates/deployment.yaml`:
- Around line 80-82: The signing-keys Secret volume in the deployment template
is using Kubernetes’ default file permissions, which leaves the ES256 private
key too broadly readable. Update the volume definition for the signing-keys
Secret in the deployment manifest to set defaultMode to 0440, keeping access
limited to root and the fsGroup already configured. Use the existing
signing-keys secret mount block as the place to apply the change.

In `@src/backend/services/authenticator/src/api/handlers.rs`:
- Around line 390-417: The me handler returns session details after
resolve_by_token without verifying that the session is still valid, unlike authz
which checks expiry before continuing. Update me to perform the same now-based
expiration guard using record.expires_at and record.absolute_expires_at before
building the JSON response, and return unauthenticated() when the session is
expired. Reuse the existing session resolution flow in me and keep the expiry
check aligned with authz so both handlers enforce the same validity rules.

In `@src/backend/services/authenticator/src/config.rs`:
- Around line 158-178: AuthenticatorConfig::validate currently only checks
numeric invariants, so boot can succeed with required string fields left empty.
Extend AuthenticatorConfig::validate to fail fast when gateway_issuer,
redirect_uri, idp.issuer_url, idp.client_id, idp.client_secret, or identity_url
are empty, using clear ensure-style checks alongside the existing jwt/session
validations. Keep the change localized to AuthenticatorConfig::validate and
align the error messages with the existing validation style.

In `@src/backend/services/authenticator/src/cookie.rs`:
- Around line 30-40: The Cookie lookup in read_session_token currently aborts on
a single non-UTF8 header because header.to_str().ok()? returns None; change this
so malformed COOKIE entries are skipped and the loop continues checking later
headers for the prefix. Keep the logic in read_session_token and the inner
header iteration intact, but replace the early return-on-error behavior with
per-header filtering so a bad header cannot prevent finding a valid __Host-sid
value elsewhere.

In `@src/backend/services/authenticator/src/main.rs`:
- Around line 95-99: Commands::Check in main should not stop at parsing
AppConfig; it needs to run AuthenticatorConfig::validate() before printing
configuration OK. Update the check path to construct or access the
AuthenticatorConfig and invoke its validation logic so cross-field rules like
jwt_reissue_after_seconds < jwt_ttl_seconds and session_ttl_seconds <=
session_absolute_lifetime_seconds are enforced. Keep the existing command flow,
but only return Ok(()) after validation succeeds.

In `@src/backend/services/authenticator/src/oidc.rs`:
- Around line 255-263: The OIDC token verification in `decode::<IdTokenClaims>`
currently trusts `header.alg` when building `Validation`, which allows
unintended RSA-family variants. Update the `Validation` setup in `oidc.rs` to
explicitly allow only RS256, keeping the existing audience, issuer, and expiry
checks unchanged. Use the `header.alg`-independent path around
`DecodingKey::from_rsa_components` so the `id_token` is rejected unless it is
signed with RS256.

In `@src/backend/services/authenticator/src/session.rs`:
- Around line 354-371: `revoke_user_sessions` leaves expired session IDs behind
in `user_sessions_key`, causing stale ZSET members to accumulate. Update the
cleanup loop in `revoke_user_sessions` (and, if needed, `revoke_session`) so
stale members are removed from the ZSET even when the session hash is already
gone, using the existing `revoke_session`/`user_sessions_key` flow to locate and
delete invalid entries.

In `@src/backend/services/authenticator/tests/run-e2e.sh`:
- Around line 55-58: The readiness loop in run-e2e.sh currently exits silently
after 30 attempts if the JWKS endpoint never responds, which makes failures
opaque. Update the loop around the curl check so it tracks whether the
authenticator became ready, and after the retries are exhausted, fail explicitly
with a clear “service not ready” error message before continuing. Keep the fix
localized to the existing readiness check logic and the curl probe for
/.well-known/jwks.json.

---

Nitpick comments:
In `@src/backend/services/authenticator/Dockerfile`:
- Around line 66-68: The watchexec download/install step in the Dockerfile does
not verify the release artifact integrity, so update the release fetch flow to
validate the archive against a published SHA256 checksum before extracting it.
Add checksum retrieval and verification alongside the existing curl and tar
pipeline in the watchexec install block, and keep the install step gated on a
successful match so the image build fails on tampering or corruption.

In `@src/backend/services/authenticator/helm/templates/deployment.yaml`:
- Around line 64-65: The container securityContext in the deployment template
should be hardened further beyond allowPrivilegeEscalation and non-root. Update
the pod/container spec to add readOnlyRootFilesystem and a RuntimeDefault
seccompProfile, and verify the authenticator app does not need writable paths
under the image filesystem. If it writes to the configured home_dir (/app/data),
adjust the deployment to mount a writable volume such as emptyDir for that path
while keeping the root filesystem read-only.

In `@src/backend/services/authenticator/tests/run-e2e.sh`:
- Around line 45-46: The environment variable assignment for the authenticator
test setup uses an empty value with a trailing space, which triggers shellcheck
SC1007. Update the assignment in run-e2e.sh to use an explicit empty string for
APP__gears__authenticator__config__identity_url, and leave the adjacent
APP__gears__authenticator__config__gateway_issuer assignment unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ade23f98-d93b-4c4e-8877-3071ddb8dd9c

📥 Commits

Reviewing files that changed from the base of the PR and between 14f5663 and b19032a.

⛔ Files ignored due to path filters (2)
  • deploy/compose/authenticator-dev-keys/current.pem is excluded by !**/*.pem
  • src/backend/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • .env.compose.example
  • .github/workflows/build-images.yml
  • deploy/compose/authenticator-dev-keys/README.md
  • docker-compose.yml
  • docs/components/backend/authenticator/DESIGN.md
  • src/backend/Cargo.toml
  • src/backend/libs/authenticator-sdk/Cargo.toml
  • src/backend/libs/authenticator-sdk/src/lib.rs
  • src/backend/services/authenticator/Cargo.toml
  • src/backend/services/authenticator/Dockerfile
  • src/backend/services/authenticator/config/insight.yaml
  • src/backend/services/authenticator/helm/Chart.yaml
  • src/backend/services/authenticator/helm/templates/_helpers.tpl
  • src/backend/services/authenticator/helm/templates/configmap.yaml
  • src/backend/services/authenticator/helm/templates/deployment.yaml
  • src/backend/services/authenticator/helm/templates/service.yaml
  • src/backend/services/authenticator/helm/values.yaml
  • src/backend/services/authenticator/src/api/error.rs
  • src/backend/services/authenticator/src/api/handlers.rs
  • src/backend/services/authenticator/src/api/mod.rs
  • src/backend/services/authenticator/src/config.rs
  • src/backend/services/authenticator/src/cookie.rs
  • src/backend/services/authenticator/src/gear.rs
  • src/backend/services/authenticator/src/identity.rs
  • src/backend/services/authenticator/src/jwt.rs
  • src/backend/services/authenticator/src/local_client.rs
  • src/backend/services/authenticator/src/main.rs
  • src/backend/services/authenticator/src/oidc.rs
  • src/backend/services/authenticator/src/session.rs
  • src/backend/services/authenticator/tests/e2e_login_loop.rs
  • src/backend/services/authenticator/tests/run-e2e.sh

Comment on lines +80 to +82
- name: signing-keys
secret:
secretName: {{ required "signingKeysSecret is required (mount the ES256 current.pem/previous.pem)" .Values.signingKeysSecret | quote }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Set defaultMode: 0440 on the signing-keys Secret volume.

The ES256 private key (current.pem) is mounted with Kubernetes' default Secret volume mode 0644, making it world-readable inside the container. With fsGroup: 1000 already set, defaultMode: 0440 restricts read access to root and the fsGroup only — the non-root container user (UID 1000) can still read via group membership, but other UIDs cannot.

🔒️ Proposed fix
       - name: signing-keys
         secret:
           secretName: {{ required "signingKeysSecret is required (mount the ES256 current.pem/previous.pem)" .Values.signingKeysSecret | quote }}
+          defaultMode: 0440
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: signing-keys
secret:
secretName: {{ required "signingKeysSecret is required (mount the ES256 current.pem/previous.pem)" .Values.signingKeysSecret | quote }}
- name: signing-keys
secret:
secretName: {{ required "signingKeysSecret is required (mount the ES256 current.pem/previous.pem)" .Values.signingKeysSecret | quote }}
defaultMode: 0440
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/authenticator/helm/templates/deployment.yaml` around
lines 80 - 82, The signing-keys Secret volume in the deployment template is
using Kubernetes’ default file permissions, which leaves the ES256 private key
too broadly readable. Update the volume definition for the signing-keys Secret
in the deployment manifest to set defaultMode to 0440, keeping access limited to
root and the fsGroup already configured. Use the existing signing-keys secret
mount block as the place to apply the change.

Comment thread src/backend/services/authenticator/src/api/handlers.rs
Comment on lines +158 to +178
impl AuthenticatorConfig {
/// Validate cross-field invariants that the type system can't express.
///
/// # Errors
/// Returns an error when a lifetime relationship is nonsensical (e.g. the
/// reissue age is not strictly below the JWT TTL, which would erase the
/// travel margin the whole design depends on).
pub fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.jwt_reissue_after_seconds < self.jwt_ttl_seconds,
"jwt_reissue_after_seconds ({}) must be < jwt_ttl_seconds ({})",
self.jwt_reissue_after_seconds,
self.jwt_ttl_seconds
);
anyhow::ensure!(
self.session_ttl_seconds <= self.session_absolute_lifetime_seconds,
"session_ttl_seconds must be <= session_absolute_lifetime_seconds"
);
Ok(())
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

validate() should reject empty required fields at boot.

gateway_issuer, redirect_uri, idp.issuer_url, idp.client_id, idp.client_secret, and identity_url all default to empty strings. The gear starts successfully with these empty, failing only on first request with a less clear error. redis_url is already fail-fast-checked in SessionManager::connect; the same should apply to the other required fields here.

🛡️ Proposed validation additions
 pub fn validate(&self) -> anyhow::Result<()> {
     anyhow::ensure!(
         self.jwt_reissue_after_seconds < self.jwt_ttl_seconds,
         "jwt_reissue_after_seconds ({}) must be < jwt_ttl_seconds ({})",
         self.jwt_reissue_after_seconds,
         self.jwt_ttl_seconds
     );
     anyhow::ensure!(
         self.session_ttl_seconds <= self.session_absolute_lifetime_seconds,
         "session_ttl_seconds must be <= session_absolute_lifetime_seconds"
     );
+    anyhow::ensure!(!self.gateway_issuer.is_empty(), "gateway_issuer is required");
+    anyhow::ensure!(!self.redirect_uri.is_empty(), "redirect_uri is required");
+    anyhow::ensure!(!self.identity_url.is_empty(), "identity_url is required");
+    anyhow::ensure!(!self.idp.issuer_url.is_empty(), "idp.issuer_url is required");
+    anyhow::ensure!(!self.idp.client_id.is_empty(), "idp.client_id is required");
+    anyhow::ensure!(!self.idp.client_secret.is_empty(), "idp.client_secret is required");
     Ok(())
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
impl AuthenticatorConfig {
/// Validate cross-field invariants that the type system can't express.
///
/// # Errors
/// Returns an error when a lifetime relationship is nonsensical (e.g. the
/// reissue age is not strictly below the JWT TTL, which would erase the
/// travel margin the whole design depends on).
pub fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.jwt_reissue_after_seconds < self.jwt_ttl_seconds,
"jwt_reissue_after_seconds ({}) must be < jwt_ttl_seconds ({})",
self.jwt_reissue_after_seconds,
self.jwt_ttl_seconds
);
anyhow::ensure!(
self.session_ttl_seconds <= self.session_absolute_lifetime_seconds,
"session_ttl_seconds must be <= session_absolute_lifetime_seconds"
);
Ok(())
}
}
impl AuthenticatorConfig {
/// Validate cross-field invariants that the type system can't express.
///
/// # Errors
/// Returns an error when a lifetime relationship is nonsensical (e.g. the
/// reissue age is not strictly below the JWT TTL, which would erase the
/// travel margin the whole design depends on).
pub fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.jwt_reissue_after_seconds < self.jwt_ttl_seconds,
"jwt_reissue_after_seconds ({}) must be < jwt_ttl_seconds ({})",
self.jwt_reissue_after_seconds,
self.jwt_ttl_seconds
);
anyhow::ensure!(
self.session_ttl_seconds <= self.session_absolute_lifetime_seconds,
"session_ttl_seconds must be <= session_absolute_lifetime_seconds"
);
anyhow::ensure!(!self.gateway_issuer.is_empty(), "gateway_issuer is required");
anyhow::ensure!(!self.redirect_uri.is_empty(), "redirect_uri is required");
anyhow::ensure!(!self.identity_url.is_empty(), "identity_url is required");
anyhow::ensure!(!self.idp.issuer_url.is_empty(), "idp.issuer_url is required");
anyhow::ensure!(!self.idp.client_id.is_empty(), "idp.client_id is required");
anyhow::ensure!(!self.idp.client_secret.is_empty(), "idp.client_secret is required");
Ok(())
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/authenticator/src/config.rs` around lines 158 - 178,
AuthenticatorConfig::validate currently only checks numeric invariants, so boot
can succeed with required string fields left empty. Extend
AuthenticatorConfig::validate to fail fast when gateway_issuer, redirect_uri,
idp.issuer_url, idp.client_id, idp.client_secret, or identity_url are empty,
using clear ensure-style checks alongside the existing jwt/session validations.
Keep the change localized to AuthenticatorConfig::validate and align the error
messages with the existing validation style.

Comment thread src/backend/services/authenticator/src/cookie.rs Outdated
Comment thread src/backend/services/authenticator/src/main.rs
Comment thread src/backend/services/authenticator/src/oidc.rs Outdated
Comment thread src/backend/services/authenticator/src/session.rs
Comment thread src/backend/services/authenticator/tests/run-e2e.sh Outdated

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/services/authenticator/tests/run-e2e.sh`:
- Around line 43-48: The JWKS readiness check in run-e2e.sh can currently loop
without ever failing, so the script may continue into the login test and report
a misleading connection error. Update the startup wait logic around the JWKS
probe in run-e2e.sh to enforce an explicit timeout and exit with a clear failure
if the JWKS endpoint never becomes ready, keeping the failure tied to the
authenticator/JWKS startup step.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 877d0fda-9ce8-4924-8170-bb724d05c86e

📥 Commits

Reviewing files that changed from the base of the PR and between b19032a and 6c6b091.

⛔ Files ignored due to path filters (1)
  • src/backend/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • .env.compose.example
  • docker-compose.yml
  • docs/components/backend/authenticator/DESIGN.md
  • src/backend/libs/authenticator-sdk/Cargo.toml
  • src/backend/libs/authenticator-sdk/src/lib.rs
  • src/backend/services/analytics/Dockerfile
  • src/backend/services/api-gateway/Dockerfile
  • src/backend/services/authenticator/config/insight.yaml
  • src/backend/services/authenticator/helm/templates/configmap.yaml
  • src/backend/services/authenticator/src/api/handlers.rs
  • src/backend/services/authenticator/src/config.rs
  • src/backend/services/authenticator/src/gear.rs
  • src/backend/services/authenticator/src/identity.rs
  • src/backend/services/authenticator/src/jwt.rs
  • src/backend/services/authenticator/src/local_client.rs
  • src/backend/services/authenticator/src/main.rs
  • src/backend/services/authenticator/tests/e2e_login_loop.rs
  • src/backend/services/authenticator/tests/identity-stub.py
  • src/backend/services/authenticator/tests/run-e2e.sh
  • src/backend/services/fakeidp/Dockerfile
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/backend/services/authenticator/src/main.rs
  • src/backend/services/authenticator/config/insight.yaml
  • src/backend/services/authenticator/tests/e2e_login_loop.rs
  • src/backend/services/authenticator/src/jwt.rs
  • src/backend/services/authenticator/src/gear.rs
  • src/backend/services/authenticator/src/config.rs
  • src/backend/services/authenticator/helm/templates/configmap.yaml
  • src/backend/services/authenticator/src/api/handlers.rs

Comment thread src/backend/services/authenticator/tests/run-e2e.sh
@cyberantonz

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai — triaged all 8 findings. Fixed the valid ones in fa2f4e5; details + the two I'm declining:

Fixed

  • oidc.rs — algorithm confusion (RFC 8725): id_token verification now pins Validation::new(Algorithm::RS256) instead of trusting the token's own alg header.
  • session.rs — revoke_user_sessions ZSET leak (Major): stale members whose session hash already expired are now ZREM'd in the loop.
  • handlers.rs — /auth/me expiry: applies the same expires_at / absolute_expires_at guard as /internal/authz.
  • config.rs — validate() required fields: fails fast on empty gateway_issuer, redirect_uri, signing_keys_path, identity_url, idp.issuer_url, idp.client_id. idp.client_secret is intentionally left optional — public OIDC clients (the dev fakeidp) use PKCE with no secret.
  • deployment.yaml — Secret defaultMode: 0440 on the signing-keys volume.
  • run-e2e.sh — readiness: added wait_ready for fakeidp + the identity stub + the authenticator, failing loudly (with a log tail) if any service never comes up.

Already resolved by a later refactor

  • cookie.rs — non-UTF8 Cookie header aborts lookup: cookie.rs was rewritten to use axum-extra's CookieJar (extractor + response part); there's no manual Cookie-header parsing / to_str().ok()? short-circuit anymore.

Declining (with reasons)

  • main.rs — check should run AuthenticatorConfig::validate(): the required leaf fields are injected per-deployment via APP__gears__authenticator__config__* env at runtime and are empty in the committed YAML, so running the full field-validation in a bare check (no deployment env) would false-fail. Fail-fast validation runs at gear init on run (cfg.validate()?), which is where the real merged config exists. check stays a structural parse.
  • Dockerfile — watchexec SHA256 pin (nitpick): watchexec is the shared dev-only auto-reload wrapper (only invoked when ENABLE_AUTO_RELOAD=true); every service Dockerfile (api-gateway, analytics, fakeidp) downloads it identically. Hardening it in just the authenticator image would diverge from the established pattern — better as a repo-wide follow-up across all Dockerfiles.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@cyberantonz Thanks for the thorough triage and the detailed writeup on each item — this all looks solid.

The fixes address the core concerns well:

  • Pinning Algorithm::RS256 explicitly instead of trusting the token header closes the algorithm-confusion vector per RFC 8725.
  • ZREM-ing stale ZSET members during revoke_user_sessions prevents the index from growing unbounded.
  • Aligning /auth/me expiry checks with /internal/authz keeps the two endpoints' trust semantics consistent.
  • Making idp.client_secret optional while enforcing the other required fields is the right call for PKCE-based public clients like fakeidp.
  • defaultMode: 0440 on the signing-keys Secret volume and the wait_ready readiness loop in run-e2e.sh are both good hardening/reliability improvements.

Agreed that the cookie.rs rewrite to axum-extra's CookieJar moots the non-UTF8 header concern — that's a strictly better approach than manual parsing.

On the two declines:

  • check staying a structural parse makes sense given the env-injection model (APP__gears__authenticator__config__*) — running full field validation without deployment env would indeed produce false failures, and init-time cfg.validate()? is the correct place for that enforcement.
  • Deferring the watchexec SHA256 pin to a repo-wide follow-up across all service Dockerfiles is reasonable — fixing it in isolation here would just create inconsistency.

Nice work closing these out in fa2f4e5.

🐰✨


✏️ Learnings added
Learnt from: cyberantonz
URL: https://github.com/constructorfabric/insight/pull/1686

Timestamp: 2026-07-08T16:47:33.134Z
Learning: Repo: constructorfabric/insight. In `src/backend/services/authenticator/src/config.rs`, `AuthenticatorConfig::validate()` intentionally treats `idp.client_secret` as optional (not required to be non-empty) because public OIDC clients (e.g., the dev `fakeidp`) use authorization code + PKCE without a client secret. Required fields enforced by `validate()` are: `gateway_issuer`, `redirect_uri`, `signing_keys_path`, `identity_url`, `idp.issuer_url`, `idp.client_id`.

Learnt from: cyberantonz
URL: https://github.com/constructorfabric/insight/pull/1686

Timestamp: 2026-07-08T16:47:33.134Z
Learning: Repo: constructorfabric/insight. In `src/backend/services/authenticator/src/main.rs`, the CLI's `check` subcommand intentionally does not run `AuthenticatorConfig::validate()`'s full field validation, because required leaf config fields (e.g., gateway_issuer, redirect_uri, signing_keys_path, identity_url, idp.issuer_url, idp.client_id) are injected per-deployment via `APP__gears__authenticator__config__*` environment variables and are empty in the committed YAML. Running full validation in a bare `check` (without deployment env) would false-fail. `check` is meant to remain a structural config parse; full fail-fast field validation runs at gear `init` on `run` via `cfg.validate()?`, where the real merged config exists.

Learnt from: cyberantonz
URL: https://github.com/constructorfabric/insight/pull/1686

Timestamp: 2026-07-08T16:47:33.134Z
Learning: Repo: constructorfabric/insight. `watchexec` (used for `ENABLE_AUTO_RELOAD=true` dev auto-reload) is downloaded identically and unpinned (no SHA256 checksum) across all backend service Dockerfiles (api-gateway, analytics, fakeidp, authenticator) as an established repo-wide pattern. Adding a SHA256 pin to just one service's Dockerfile would diverge from this pattern; any hardening of the watchexec download should be done consistently across all service Dockerfiles as a repo-wide follow-up, not per-service.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@cyberantonz
cyberantonz force-pushed the nginx-authenticator-04 branch from fa2f4e5 to 7d0771d Compare July 8, 2026 17:05

@ktursunov ktursunov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please add coverage to the coverage gate

@cyberantonz
cyberantonz force-pushed the nginx-authenticator-04 branch from 7d0771d to 4bfe858 Compare July 9, 2026 08:48
@cyberantonz

Copy link
Copy Markdown
Contributor Author

Please add coverage to the coverage gate

added

cyberantonz and others added 11 commits July 9, 2026 13:47
…JWT exchange

New authenticator gear (nginx+auth step 04): OIDC code+PKCE login, opaque
Redis sessions (asm: schema, identity/credential split), ES256 linked-JWT
minting + JWKS, and the /internal/authz cookie->JWT exchange with reissue-
ahead. authenticator-sdk carries the AuthenticatorClientV1 contract.

§9.6 resolved: mint ES256 (downstream oidc-authn-plugin validates RS256/ES256
today; EdDSA would need a non-trivial plugin change, deferred to Phase 3).

Bumps cf-gears-* toolkit pins to gears-rust HEAD (toolkit 0.6.13, auth/security
0.7.2, api-gateway 0.2.9, ...). 15 unit tests; clippy clean.

Refs constructorfabric#1583

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
- docker-compose: authenticator service (8083) + dev ES256 signing keys;
  fakeidp issuer -> in-network fakeidp:8084 (authenticator is now the consumer)
- helm chart insight-authenticator (deployment + configmap + service, signing-
  keys Secret mount, envFrom leaf-config Secret, probes)
- build-images.yml: authenticator path filter + build + merge (multi-arch,
  attested) jobs, wired into bump-descriptors + publish-chart gates
- e2e integration test (login -> authz -> me -> logout -> 401) + run-e2e.sh
  runner; proven green locally against fakeidp
- DESIGN: §9.6 recorded as RESOLVED = ES256; cfs toc + validate clean

Refs constructorfabric#1583

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…#1687 in code marker

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…ng Dockerfiles

Adding the two new workspace members broke the analytics / api-gateway /
fakeidp image builds: each Dockerfile's dependency-cache layer copies an
explicit list of member Cargo.toml + stub sources so the workspace resolves,
and the new members weren't listed (cargo: 'failed to load manifest for
workspace member libs/authenticator-sdk'). Verified: analytics + authenticator
images now build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…ors, clarify api-gateway naming

- Signing keys: no more checked-in dev key. When signing_keys_path is empty the
  gear generates an EPHEMERAL in-process ES256 key (dev/CI only, loudly warned),
  like fakeidp; prod still mounts a stable-key Secret. Deletes
  deploy/compose/authenticator-dev-keys and the compose bind-mount.
- SDK: AuthenticatorClientV1 returns Result<u64, CanonicalError> — dropped the
  hand-rolled AuthenticatorError enum; use canonical errors like every gear
  (toolkit ADR 0005). LocalClient maps store-down to ServiceUnavailable (503).
- Docs: clarify that the 'api-gateway' gear is the gears-rust toolkit REST-host
  system gear (cf-gears-api-gateway) every service runs on, NOT the Insight
  platform gateway service the nginx edge replaces (config/helm/main.rs).

e2e loop re-verified green against fakeidp with the ephemeral-key path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
Per review: no first-admin/empty-table machinery and no RBAC/ACL in step 04 —
both move to a separate universe-admin initiative. Local dev seeds the persons
table; an unknown person is denied (403, audited login_denied_unknown_person),
and every session carries default_roles only.

Removed: bootstrap_first_admin config, the one-shot Redis claim, the
PersonResolver bootstrap method + is_universe_admin + deterministic id, and the
admin-role injection. compose points identity_url at the seeded Identity;
run-e2e.sh gains a tiny Identity stub so the loop resolves a person without the
full .NET service. DESIGN §4.6 / DD-AUTH-08 marked deferred.

Refs constructorfabric#1583

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…ecord + route-group cleanups

- deps to latest: jsonwebtoken 9->10 (rust_crypto provider), p256 0.13->0.14
  (ToSec1Point + Generate API), cookies via axum-extra 0.12 CookieJar (replaces
  hand-rolled Set-Cookie building + Cookie-header parsing).
- signing keys: no in-binary key generation. The gear always loads from the
  mounted signing_keys_path (fail closed). dev-compose.sh generates a dev key
  (openssl, gitignored) and compose bind-mounts it; run-e2e.sh does the same.
- SessionRecord owns its Redis mapping (to_fields/from_map) instead of a free
  function + inline vec.
- build_operations split into register_auth/internal/well_known routes.

Verified: cargo clippy clean, 15 unit tests, e2e loop green at runtime
(mounted key, jsonwebtoken 10 rust_crypto, p256 0.14), authenticator image builds.

Refs constructorfabric#1583

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
- oidc: pin id_token verification to RS256 instead of trusting the token's own
  `alg` header (RFC 8725 algorithm-confusion guard).
- session: revoke_user_sessions now trims stale ZSET members whose session hash
  already expired, so the user-session index can't accumulate dead ids.
- handlers: /auth/me applies the same expiry guard as /internal/authz.
- config: validate() fails fast on empty required fields (gateway_issuer,
  redirect_uri, signing_keys_path, identity_url, idp.issuer_url, idp.client_id;
  client_secret stays optional for public/PKCE clients).
- helm: signing-keys Secret volume mounts with defaultMode 0440.
- run-e2e.sh: wait for fakeidp + identity-stub readiness and fail loudly if a
  service never comes up.

cargo clippy clean, 15 unit tests, e2e loop green.

Refs constructorfabric#1583

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
The directory is created at runtime by dev-compose.sh (mkdir -p) and the key is
gitignored, so there is nothing to keep committed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…/from_map)

Mirrors SessionRecord — put/take_login_state no longer inline the field list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…ire coverage gate

- oidc.rs now builds on `openidconnect` (CoreClient) for discovery, PKCE, code
  exchange, and id_token validation (signature/iss/aud/nonce/exp/alg) instead of
  hand-rolled discovery + JWKS fetch + RSA-component parsing + manual checks
  (NGINX_BFF §11.7). The two non-standard bits the Core typed API doesn't expose
  — the interim `tenants` claim (constructorfabric#1687) and OIDC `sid` — are read from the
  already-validated id_token payload; RP-initiated logout keeps a tiny
  end_session_endpoint read (not part of core discovery).
- coverage gate: register authenticator + authenticator-sdk in
  scripts/ci/components.py. cover=False (mirrors api-gateway): the crate's
  security-critical flow is proven by the e2e login-loop, which drives the
  server as a separate process and so can't feed `cargo llvm-cov`; fmt/clippy/
  test still gate the pipeline.

clippy clean, 15 unit tests, e2e login loop green (two users, no panics).

Refs constructorfabric#1583

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz
cyberantonz force-pushed the nginx-authenticator-04 branch from 00972c7 to ab97ab7 Compare July 9, 2026 11:47
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

Format check failed CI — the crate was never rustfmt'd. No logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
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.

4 participants