Skip to content

Retain OAuth certs across cert-manager renewal without mass logout - #2489

Open
hahn-kev-bot wants to merge 3 commits into
developfrom
oauth-cert-rotation
Open

Retain OAuth certs across cert-manager renewal without mass logout#2489
hahn-kev-bot wants to merge 3 commits into
developfrom
oauth-cert-rotation

Conversation

@hahn-kev-bot

@hahn-kev-bot hahn-kev-bot commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Now when our certs rotate we remember the old one so we don't logout everyone. I'll give an example for the oauth-signing-cert, there's also an encryption one but they work the same for this purpose.

How this works:
Day 1, cert A is created, users login and it's used for auth. Stored in the secret oauth-signing-cert
Day 2, the new job oauth-cert-retainer runs for the first time, copies oauth-signing-cert to oauth-signing-cert-last-seen and oauth-signing-cert-previous because they don't exist
Day 3, oauth-cert-retainer runs again, compares oauth-signing-cert == oauth-signing-cert-last-seen, both are cert A so does nothing. Etc for each day following.
Day 75, cert A is 15 days from expiring, cert-manager issues a new one, cert B which overwrites the secret oauth-signing-cert.
Day 75/76, oauth-cert-retainer runs again, compares oauth-signing-cert == oauth-signing-cert-last-seen, false this time because last seen is cert A, but oauth-signing-cert is cert B, so it takes oauth-signing-cert-last-seen moves it into oauth-signing-cert-previous, then updates oauth-signing-cert-last-seen to be cert B.
Day 150, cert B is 1t days from expiring, cert C is created and overwrites oauth-signing-cert
Day 150/151, the retainer job runs, sees that there's a new cert and updates previous and last seen.

Meanwhile, asp.net is watching the folders with oauth-signing-cert, oauth-signing-cert-last-seen and oauth-signing-cert-previous deduplicates them, and gives them to OpenIddict which will check each one it turn when authenticating a refresh token. It will use the newest one when creating new tokens which will then be valid for the next 90 days.

One thing to note with this current setup is that a refresh token is valid for 14 days today, and our certs are renewed 15 days before they expire. This means if someone gets a brand new token right before a new cert is created, then it's valid for 14 days, and the cert to validate it is good for 15 days. No problems. However, if we want to increase the length that a refresh token is valid for, then we also need to increase how early we create new certs, otherwise we might create a token that should be valid for 20 days, but the cert it's based on will expire before then making the extra long refresh token length moot.

Another fun thing to think about, is that if we were to add a weekly background job to sync projects, those will naturally need to authenticate with Lexbox update their refresh token, so users would functionally never be logged out due to an expired token, where today users would have to use the app every 14 days or less to stay logged in.

Summary

  • Load signing and encryption PEMs from current, last-seen, and previous mount paths in non-Development environments, with deduplication by thumbprint.
  • Poll mounted PEM content (~30s) and reload OpenIddict server and validation credentials so renewed certs apply without restarting the API.
  • Add a daily oauth-cert-retainer CronJob that shifts cert-manager TLS secrets into last-seen/previous copies, plus LexBox volume mounts; local-dev keeps optional retainer delete and patches JWT/maildev for prod-like OAuth testing.

Fixes #2332. Relates to #843.

Test plan

  • dotnet test backend/Testing --filter FullyQualifiedName~OauthPem
  • Local k8s: cert-manager renew → retainer job → refresh token still works after API restart
  • Second renew → wait for hot reload → refresh without API restart (oauth2c with offline_access)

…erts in cluster.

Add multi-path PEM loading and polled OpenIddict server/validation reload so refresh tokens survive cert-manager renewal without API restarts; add a daily CronJob to copy current/last-seen/previous secrets and mount them on LexBox. Adjust local-dev patches (JWT secret, maildev env, optional retainer delete) for prod-like OAuth testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions github-actions Bot added the 📦 Lexbox issues related to any server side code, fw-headless included label Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 45022da8-3df0-4af2-85f1-581361124356

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

OAuth certificate handling now loads current and retained PEM slots, reloads OpenIddict server and validation credentials when PEM content changes, and preserves prior keys through Kubernetes-managed secret retention and deployment mounts.

Changes

OAuth certificate rotation

Layer / File(s) Summary
PEM credential loading and registration
backend/LexBoxApi/Auth/*
Adds shared PEM paths, distinct certificate loading, OpenIddict signing/encryption configuration, and non-development registration wiring.
PEM change detection and option reload
backend/LexBoxApi/Auth/OauthPemOptionsChangeTokenSource.cs, backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs
Polls certificate and key content, invalidates server and validation options on changes, and tests credential rebuilding and continued validation of tokens protected by prior keys.
Kubernetes certificate retention and mounts
deployment/base/kustomization.yaml, deployment/base/oauth-cert-retainer.yaml, deployment/base/lexbox-deployment.yaml
Adds a scheduled retention job, RBAC, retained certificate secrets, and optional mounts for signing and encryption slots.
Local development deployment overrides
deployment/local-dev/delete-oauth-cert-retainer.yaml, deployment/local-dev/kustomization.yaml, deployment/local-dev/lexbox-deployment.patch.yaml
Removes retention resources from local development and updates environment overrides.
Estimated code review effort: 4 (Complex) ~60 minutes

Poem

I’m a bunny with keys in a neat little row,
Current hops forward, old secrets still glow.
PEM petals turn, tokens stay bright,
Fresh credentials bloom overnight.
Sproing goes the reload—no logout in sight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #2332 by retaining prior cert material and reloading OpenIddict so refresh tokens survive rotation.
Out of Scope Changes check ✅ Passed The deployment and local-dev changes support the OAuth cert retention flow and do not appear unrelated to the PR goal.
Title check ✅ Passed The title clearly summarizes the main change: retaining OAuth certs across cert-manager renewal to avoid logouts.
Description check ✅ Passed The description matches the changeset and explains the cert-retention, hot-reload, and deployment updates.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch oauth-cert-rotation

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.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (4)
backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs (1)

212-222: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fragile subject-string sniffing to stagger certificate validity.

Determining NotAfter via subject.Contains("New", StringComparison.Ordinal) is implicit and easy to break silently if a future test adds/renames a cert subject. Prefer an explicit parameter.

♻️ Proposed refactor
-    private static X509Certificate2 CreateSelfSigned(string subject)
+    private static X509Certificate2 CreateSelfSigned(string subject, bool longerLived = false)
     {
         using var rsa = RSA.Create(2048);
         var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
         // Stagger NotAfter so "preferred" signing cert selection is deterministic in redeem-after-swap.
         var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
-        var notAfter = subject.Contains("New", StringComparison.Ordinal)
-            ? DateTimeOffset.UtcNow.AddYears(2)
-            : DateTimeOffset.UtcNow.AddYears(1);
+        var notAfter = longerLived
+            ? DateTimeOffset.UtcNow.AddYears(2)
+            : DateTimeOffset.UtcNow.AddYears(1);
         return request.CreateSelfSigned(notBefore, notAfter);
     }
🤖 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 `@backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs` around
lines 212 - 222, Update CreateSelfSigned to accept an explicit parameter
indicating the desired certificate validity duration or NotAfter value, and use
that parameter instead of inferring it from subject.Contains("New"). Update each
call site to pass the appropriate value so deterministic preferred-certificate
selection remains unchanged.
backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs (1)

139-182: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shared OAuth PEM test fixtures duplicated across two new test files. Both new test files independently define identical GetThumbprint/GetThumbprints, CreateSelfSigned, WritePemPair, and TempPemRoot helpers; the shared root cause is the lack of a common test-support module for OAuth PEM fixtures.

  • backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs#L139-L182: extract these helpers into a shared internal class (e.g. OauthPemTestHelpers) in the Testing.LexBoxApi.Auth namespace.
  • backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs#L200-L254: remove the duplicated copies and reference the shared helper class instead.
🤖 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 `@backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs` around
lines 139 - 182, Extract the duplicated OAuth PEM fixture helpers from
backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs lines 139-182
into a shared internal OauthPemTestHelpers class in the Testing.LexBoxApi.Auth
namespace, preserving GetThumbprint/GetThumbprints, CreateSelfSigned,
WritePemPair, and TempPemRoot behavior. Remove the duplicate helper
implementations from
backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs lines 200-254
and update that test to reference the shared class.
deployment/base/oauth-cert-retainer.yaml (2)

55-61: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound job runtime with activeDeadlineSeconds.

No deadline is set on the jobTemplate. If a kubectl call hangs (e.g., API server unreachable), the job pod can run indefinitely; combined with concurrencyPolicy: Forbid, this silently blocks all future scheduled runs until manually intervened.

⏱️ Proposed fix
   jobTemplate:
     spec:
       backoffLimit: 1
+      activeDeadlineSeconds: 300
       template:
🤖 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 `@deployment/base/oauth-cert-retainer.yaml` around lines 55 - 61, Add an
appropriate activeDeadlineSeconds value to the CronJob’s jobTemplate.spec,
alongside backoffLimit, so hung oauth-cert-retainer pods terminate automatically
and do not block subsequent scheduled runs under concurrencyPolicy: Forbid.

63-67: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Unmaintained bitnamilegacy image for a secret-creating CronJob.

bitnamilegacy/kubectl:1.31.4 is Bitnami's frozen legacy repo (post Aug 2025 catalog restructuring) and will not receive security patches going forward. This container also holds RBAC permissions to create/patch Secrets, so running an unpatched image here increases blast radius if a vulnerability surfaces.

Consider a maintained alternative with bash+kubectl (e.g., building a minimal custom image on top of the official kubectl image, or alpine/k8s), since the comment indicates only the missing shell drove this choice.

🤖 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 `@deployment/base/oauth-cert-retainer.yaml` around lines 63 - 67, Replace the
unmaintained bitnamilegacy/kubectl image in the retain CronJob with a maintained
image that provides both bash and kubectl, preserving the existing command and
retainer script behavior. Prefer the project’s maintained custom image or an
established maintained alternative such as alpine/k8s, and keep the required
Kubernetes tooling available.
🤖 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 `@deployment/base/oauth-cert-retainer.yaml`:
- Around line 58-67: Harden the retain container in the deployment template by
adding an explicit securityContext that disables privilege escalation and
prevents running as root. Apply these settings to the container identified by
name retain, preserving its existing command, image, and arguments.

---

Nitpick comments:
In `@backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs`:
- Around line 139-182: Extract the duplicated OAuth PEM fixture helpers from
backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs lines 139-182
into a shared internal OauthPemTestHelpers class in the Testing.LexBoxApi.Auth
namespace, preserving GetThumbprint/GetThumbprints, CreateSelfSigned,
WritePemPair, and TempPemRoot behavior. Remove the duplicate helper
implementations from
backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs lines 200-254
and update that test to reference the shared class.

In `@backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs`:
- Around line 212-222: Update CreateSelfSigned to accept an explicit parameter
indicating the desired certificate validity duration or NotAfter value, and use
that parameter instead of inferring it from subject.Contains("New"). Update each
call site to pass the appropriate value so deterministic preferred-certificate
selection remains unchanged.

In `@deployment/base/oauth-cert-retainer.yaml`:
- Around line 55-61: Add an appropriate activeDeadlineSeconds value to the
CronJob’s jobTemplate.spec, alongside backoffLimit, so hung oauth-cert-retainer
pods terminate automatically and do not block subsequent scheduled runs under
concurrencyPolicy: Forbid.
- Around line 63-67: Replace the unmaintained bitnamilegacy/kubectl image in the
retain CronJob with a maintained image that provides both bash and kubectl,
preserving the existing command and retainer script behavior. Prefer the
project’s maintained custom image or an established maintained alternative such
as alpine/k8s, and keep the required Kubernetes tooling available.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 22b4901a-61cb-4a1e-a5fe-23f87fc09644

📥 Commits

Reviewing files that changed from the base of the PR and between 8540db9 and 885c449.

📒 Files selected for processing (13)
  • backend/LexBoxApi/Auth/AuthKernel.cs
  • backend/LexBoxApi/Auth/OauthPemCertificateLoader.cs
  • backend/LexBoxApi/Auth/OauthPemCertificatePaths.cs
  • backend/LexBoxApi/Auth/OauthPemOpenIddictServerConfigurer.cs
  • backend/LexBoxApi/Auth/OauthPemOptionsChangeTokenSource.cs
  • backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs
  • backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs
  • deployment/base/kustomization.yaml
  • deployment/base/lexbox-deployment.yaml
  • deployment/base/oauth-cert-retainer.yaml
  • deployment/local-dev/delete-oauth-cert-retainer.yaml
  • deployment/local-dev/kustomization.yaml
  • deployment/local-dev/lexbox-deployment.patch.yaml

Comment on lines +58 to +67
template:
spec:
serviceAccountName: oauth-cert-retainer
restartPolicy: Never
containers:
- name: retain
# Distroless rancher/kubectl has no shell; retainer script needs bash.
image: bitnamilegacy/kubectl:1.31.4
command: ["/bin/bash", "-c"]
args:

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Add container securityContext hardening.

Static analysis flags this container for allowing privilege escalation and admitting a root container. Add explicit hardening.

🔒 Proposed fix
           containers:
             - name: retain
               # Distroless rancher/kubectl has no shell; retainer script needs bash.
               image: bitnamilegacy/kubectl:1.31.4
+              securityContext:
+                allowPrivilegeEscalation: false
+                runAsNonRoot: true
+                capabilities:
+                  drop: ["ALL"]
               command: ["/bin/bash", "-c"]
📝 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
template:
spec:
serviceAccountName: oauth-cert-retainer
restartPolicy: Never
containers:
- name: retain
# Distroless rancher/kubectl has no shell; retainer script needs bash.
image: bitnamilegacy/kubectl:1.31.4
command: ["/bin/bash", "-c"]
args:
template:
spec:
serviceAccountName: oauth-cert-retainer
restartPolicy: Never
containers:
- name: retain
# Distroless rancher/kubectl has no shell; retainer script needs bash.
image: bitnamilegacy/kubectl:1.31.4
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
capabilities:
drop: ["ALL"]
command: ["/bin/bash", "-c"]
args:
🤖 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 `@deployment/base/oauth-cert-retainer.yaml` around lines 58 - 67, Harden the
retain container in the deployment template by adding an explicit
securityContext that disables privilege escalation and prevents running as root.
Apply these settings to the container identified by name retain, preserving its
existing command, image, and arguments.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📦 Lexbox issues related to any server side code, fw-headless included

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth cert rotation invalidates all refresh tokens (~every 75 days)

2 participants