Skip to content

fix(license,oidc): fail-closed licensing + OIDC secret encryption, gate provider mutations (OM5/OM2/OM3)#60

Open
PenguinzTech wants to merge 1 commit into
fix/oidc-hardeningfrom
fix/license-provider-hardening
Open

fix(license,oidc): fail-closed licensing + OIDC secret encryption, gate provider mutations (OM5/OM2/OM3)#60
PenguinzTech wants to merge 1 commit into
fix/oidc-hardeningfrom
fix/license-provider-hardening

Conversation

@PenguinzTech

@PenguinzTech PenguinzTech commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Security fixes (Track A5a) — licensing + OIDC secret/provider hardening

Stacked on #57 (base = fix/oidc-hardening). Targets release/v0.1.x.

  • OM5require_premium fell OPEN: it unlocked all premium features whenever RELEASE_MODE was unset (the default), so a prod deploy that forgot the flag gave everything away. Adds LICENSE_ENFORCED (True in ProductionConfig); when enforced, the dev unlock path is off and a misconfigured/unreachable license client fails closed (403). Domain bypass (config APP_DOMAIN, never request Host) preserved.
  • OM2 — OIDC client-secret encryption fell open to plaintext on any error and reused SECRET_KEY as the Fernet key. Now _encrypt_secret/_decrypt_secret raise on failure (never store/return unencrypted), using a dedicated OIDC_SECRET_ENCRYPTION_KEY (required in prod). Callers handle the error. TODO left for enterprise external KMS.
  • OM3update_provider and delete_provider are now @require_premium like create_provider (SSO/OIDC provider mgmt is Enterprise-tier), keeping @require_scope(system:admin).

Tests

12 new tests (fail-closed license, encryption raises, provider gating). Suite: 423 passed, 90.49% coverage.

🤖 Generated with Claude Code

Summary by Sourcery

Harden licensing and OIDC secret handling to fail closed in production, and gate all OIDC provider mutations behind premium licensing.

Bug Fixes:

  • Ensure premium licensing checks fail closed in production using a LICENSE_ENFORCED flag instead of relying on RELEASE_MODE.
  • Require a dedicated OIDC secret encryption key in production and make secret encryption/decryption raise on failure instead of falling back to plaintext.

Enhancements:

  • Gate OIDC provider update and delete endpoints behind the same premium requirements as creation, keeping admin scope checks.
  • Improve OIDC error handling and logging when client secret decryption or encryption fails.

Tests:

  • Add licensing and OIDC hardening tests covering fail-closed licensing behavior, OIDC encryption/decryption error paths, and premium gating for provider mutation endpoints.

…ate provider mutations (OM5/OM2/OM3)

- OM5: require_premium no longer unlocks all features when RELEASE_MODE is unset.
  Adds LICENSE_ENFORCED (True in ProductionConfig); when enforced, the dev unlock
  path is disabled and a misconfigured/unreachable license client fails CLOSED (403).
- OM2: OIDC client-secret encryption no longer silently returns plaintext/ciphertext
  on error — _encrypt/_decrypt raise; callers handle. Uses a dedicated
  OIDC_SECRET_ENCRYPTION_KEY (required in prod via validate()), not the JWT SECRET_KEY.
  TODO left for enterprise external KMS.
- OM3: update_provider and delete_provider now premium-gated like create_provider
  (SSO/OIDC provider management is enterprise-tier), keeping @require_scope(system:admin).

Tests: 12 new licensing/encryption/gating tests; suite 423 passed, 90.49% coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PenguinzTech PenguinzTech added this to the v0.1.x milestone Jul 14, 2026
@PenguinzTech PenguinzTech added the type:security Security fix label Jul 14, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

Hardens licensing behavior to fail-closed in production and strengthens OIDC client-secret handling by enforcing dedicated encryption keys and consistent error handling, while premium-gating all OIDC provider mutation endpoints and adding targeted tests.

Sequence diagram for OIDC secret encryption/decryption with dedicated key

sequenceDiagram
    participant Client as Client
    participant FlaskApp as FlaskApp
    participant OIDC as oidc_module
    participant Config as Config
    participant Fernet as Fernet
    participant OAuthClient as AsyncOAuth2Client

    Client->>FlaskApp: POST /oidc/providers (create_provider)
    FlaskApp->>OIDC: _encrypt_secret(client_secret)
    OIDC->>OIDC: _get_oidc_encryption_key()
    OIDC->>Config: read OIDC_SECRET_ENCRYPTION_KEY, LICENSE_ENFORCED, SECRET_KEY
    Config-->>OIDC: encryption_key or error
    alt valid_encryption_key
        OIDC->>Fernet: new Fernet(fernet_key)
        OIDC->>Fernet: encrypt(plaintext)
        Fernet-->>OIDC: ciphertext
        OIDC-->>FlaskApp: encrypted_secret
        FlaskApp->>FlaskApp: insert provider with client_secret_encrypted
        FlaskApp-->>Client: 201 created
    else encryption_error
        OIDC-->>FlaskApp: raise ValueError
        FlaskApp->>FlaskApp: logger.error Failed to encrypt client secret
        FlaskApp-->>Client: 400 Failed to encrypt client secret
    end

    Client->>FlaskApp: GET /oidc/authorize (authorize)
    FlaskApp->>OIDC: _decrypt_secret(provider.client_secret_encrypted)
    OIDC->>OIDC: _get_oidc_encryption_key()
    alt valid_encryption_key
        OIDC->>Fernet: new Fernet(fernet_key)
        OIDC->>Fernet: decrypt(ciphertext)
        Fernet-->>OIDC: plaintext
        OIDC-->>FlaskApp: client_secret
        FlaskApp->>OAuthClient: AsyncOAuth2Client(client_id, client_secret,...)
        OAuthClient-->>FlaskApp: authorization_url
        FlaskApp-->>Client: redirect to IdP
    else decryption_error
        OIDC-->>FlaskApp: raise ValueError
        FlaskApp->>FlaskApp: logger.error Failed to decrypt provider secret
        FlaskApp-->>Client: 500 Provider configuration error
    end
Loading

Flow diagram for updated require_premium licensing enforcement

flowchart LR
    A[request hits premium_gated_endpoint] --> B{LICENSE_ENFORCED}
    B -- false --> C[call wrapped endpoint func]
    B -- true --> D{_is_premium_bypassed}
    D -- true --> C
    D -- false --> E[call penguin_licensing.get_license_client]
    E --> F{client.has_tier professional}
    F -- true --> C
    F -- false --> G[return 403 premium_features_locked]
    E -. exception .-> G
Loading

File-Level Changes

Change Details Files
Introduce dedicated OIDC secret encryption key handling and fail-closed encrypt/decrypt helpers, updating all OIDC flows and provider CRUD to surface errors safely.
  • Add _get_oidc_encryption_key helper that derives a Fernet key from OIDC_SECRET_ENCRYPTION_KEY, falling back to SECRET_KEY only when LICENSE_ENFORCED is false and raising in production if unset.
  • Refactor _encrypt_secret and _decrypt_secret to use _get_oidc_encryption_key, raise ValueError on any failure instead of returning plaintext/ciphertext, and log encryption/decryption errors.
  • Wrap calls to _decrypt_secret in authorize, callback, and initiate_link_identity to catch ValueError, log provider-specific failures, and return 500 with a generic provider configuration error.
  • Update create_provider and update_provider to catch encryption failures from _encrypt_secret, log them, and respond with 400 instead of persisting unencrypted or failing silently.
  • Add @require_premium to update_provider and delete_provider and expand their docstrings to reflect premium/enterprise gating.
services/flask-backend/app/oidc.py
Change licensing to use LICENSE_ENFORCED as the primary switch, enforcing fail-closed premium checks in production with domain-based bypass and clearer logging.
  • Add LICENSE_ENFORCED flag to gate require_premium behavior, allowing all premium features when false (dev/testing) and enforcing licensing when true (production).
  • Update require_premium wrapper logic and docstring to reflect fail-open vs fail-closed behavior, ensuring misconfigured or unavailable licensing denies access with 403.
  • Adjust license-check logging to avoid leaking exception details while indicating denial of premium access.
  • Annotate init_licensing and require_premium signatures with typing.Any and Callable[..., Any].
services/flask-backend/app/licensing.py
Extend configuration to support LICENSE_ENFORCED and a required OIDC_SECRET_ENCRYPTION_KEY in production, with validation and test defaults.
  • Add LICENSE_ENFORCED default (False) and OIDC_SECRET_ENCRYPTION_KEY to base Config, sourced from environment.
  • Ensure ProductionConfig sets LICENSE_ENFORCED=True, defines OIDC_SECRET_ENCRYPTION_KEY from env, and validates that this key is present and distinct from JWT signing keys via validate().
  • Set LICENSE_ENFORCED=False in TestingConfig to keep tests in fail-open mode for premium gating.
services/flask-backend/app/config.py
Add tests covering licensing fail-closed semantics, OIDC encryption/decryption error handling, and premium gating of provider mutation endpoints.
  • Test that require_premium allows premium actions when LICENSE_ENFORCED is false and denies them with 403 when true and licensing is unavailable, including domain-based bypass behavior.
  • Verify _encrypt_secret raises when no encryption key is available in a production-like configuration and that valid encrypt/decrypt round trips work, never falling back to plaintext/ciphertext on error.
  • Assert that create_provider, update_provider, and delete_provider are effectively premium-gated by simulating license failures, and that update_provider returns 400 on encryption failure rather than updating secrets.
services/flask-backend/tests/test_licensing_hardening.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai 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.

Hey - I've found 3 issues, and left some high level feedback:

  • The repeated try/except blocks around _decrypt_secret in authorize, callback, and initiate_link_identity (including logging and standardized 500 responses) could be consolidated into a small helper to reduce duplication and keep behavior consistent.
  • Several logging calls that handle failures (OIDC secret encryption failed, OIDC secret decryption failed, OIDC authorize error for {provider_slug}) drop the underlying exception details; consider using logger.exception or including the exception in structured logging (at least at debug level) to make diagnosing production issues easier while still avoiding leaking secrets.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The repeated try/except blocks around `_decrypt_secret` in `authorize`, `callback`, and `initiate_link_identity` (including logging and standardized 500 responses) could be consolidated into a small helper to reduce duplication and keep behavior consistent.
- Several logging calls that handle failures (`OIDC secret encryption failed`, `OIDC secret decryption failed`, `OIDC authorize error for {provider_slug}`) drop the underlying exception details; consider using `logger.exception` or including the exception in structured logging (at least at debug level) to make diagnosing production issues easier while still avoiding leaking secrets.

## Individual Comments

### Comment 1
<location path="services/flask-backend/app/licensing.py" line_range="87-88" />
<code_context>
                 "penguin-licensing not available; denying premium access"
             )  # pragma: no cover
         except Exception as e:  # pragma: no cover
-            logger.error(f"License check failed: {e}")  # pragma: no cover
+            logger.error("License check failed; denying premium access")  # pragma: no cover

         return (
</code_context>
<issue_to_address>
**suggestion:** License check failure logs no exception details, which makes production diagnosis harder.

The previous log included the exception message; now it’s fully generic. For this critical premium-access gate, please retain minimal exception context in the log (e.g., `logger.error("License check failed; denying premium access: %s", e)`), while still ensuring no sensitive data is exposed to users.

```suggestion
        except Exception as e:  # pragma: no cover
            logger.error("License check failed; denying premium access: %s", e)  # pragma: no cover
```
</issue_to_address>

### Comment 2
<location path="services/flask-backend/app/oidc.py" line_range="200-201" />
<code_context>
-        return plaintext
+    except ValueError:
+        raise
+    except Exception as e:
+        logger.error(f"OIDC secret encryption failed")
+        raise ValueError("Failed to encrypt OIDC client secret") from e

</code_context>
<issue_to_address>
**suggestion:** Encryption failure logging omits the underlying exception details, limiting observability.

The failure path is correct, but the log line drops the underlying exception `e`. Please include the exception details (e.g., via `logger.error("OIDC secret encryption failed: %s", e)`) so operational teams can diagnose key/config issues without changing the raised `ValueError` behavior.

Suggested implementation:

```python
    except ValueError:
        raise
    except Exception as e:
        logger.error(f"OIDC secret encryption failed")
        raise ValueError("Failed to encrypt OIDC client secret") from e

```

```python
    except Exception as e:
        logger.error("OIDC secret encryption failed: %s", e)
        raise ValueError("Failed to encrypt OIDC client secret") from e

```
</issue_to_address>

### Comment 3
<location path="services/flask-backend/tests/test_licensing_hardening.py" line_range="204" />
<code_context>
+@pytest.mark.asyncio
+async def test_decrypt_secret_never_returns_ciphertext_as_plaintext(app) -> None:
</code_context>
<issue_to_address>
**suggestion (testing):** The assertion inside `pytest.raises` is unreachable and can be simplified

Within `with pytest.raises(ValueError):`, any code after `_decrypt_secret(bad_ciphertext)` is never executed if the exception is raised, so `result` is unused and `assert result != bad_ciphertext` is dead code.

To clarify the test’s intent, keep only `_decrypt_secret(bad_ciphertext)` inside `pytest.raises`, or move assertions that should run when no exception is raised outside that context (e.g., via a separate test or a different control flow).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines 87 to +88
except Exception as e: # pragma: no cover
logger.error(f"License check failed: {e}") # pragma: no cover
logger.error("License check failed; denying premium access") # pragma: no cover

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: License check failure logs no exception details, which makes production diagnosis harder.

The previous log included the exception message; now it’s fully generic. For this critical premium-access gate, please retain minimal exception context in the log (e.g., logger.error("License check failed; denying premium access: %s", e)), while still ensuring no sensitive data is exposed to users.

Suggested change
except Exception as e: # pragma: no cover
logger.error(f"License check failed: {e}") # pragma: no cover
logger.error("License check failed; denying premium access") # pragma: no cover
except Exception as e: # pragma: no cover
logger.error("License check failed; denying premium access: %s", e) # pragma: no cover

Comment on lines +200 to +201
except Exception as e:
logger.error(f"OIDC secret encryption failed")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Encryption failure logging omits the underlying exception details, limiting observability.

The failure path is correct, but the log line drops the underlying exception e. Please include the exception details (e.g., via logger.error("OIDC secret encryption failed: %s", e)) so operational teams can diagnose key/config issues without changing the raised ValueError behavior.

Suggested implementation:

    except ValueError:
        raise
    except Exception as e:
        logger.error(f"OIDC secret encryption failed")
        raise ValueError("Failed to encrypt OIDC client secret") from e
    except Exception as e:
        logger.error("OIDC secret encryption failed: %s", e)
        raise ValueError("Failed to encrypt OIDC client secret") from e

with pytest.raises(ValueError):
result = _decrypt_secret(bad_ciphertext)
# Should never reach here and return plaintext
assert result != bad_ciphertext

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): The assertion inside pytest.raises is unreachable and can be simplified

Within with pytest.raises(ValueError):, any code after _decrypt_secret(bad_ciphertext) is never executed if the exception is raised, so result is unused and assert result != bad_ciphertext is dead code.

To clarify the test’s intent, keep only _decrypt_secret(bad_ciphertext) inside pytest.raises, or move assertions that should run when no exception is raised outside that context (e.g., via a separate test or a different control flow).

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

Labels

type:security Security fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant