Skip to content

Conversation

anderska
Copy link

@anderska anderska commented Sep 18, 2025

OAuth2: configurable token auth, PKCE support, required group claims, and safer claim parsing

Summary

This PR enhances the OAuth2/OIDC integration with:

  • Configurable token endpoint authentication (basic, post, none)
  • Optional PKCE support via code challenge method
  • Explicit required group claim enforcement (non-empty claims)
  • Simplified, safer scopes handling and claim parsing
  • Removal of brittle token “granted scope” checks in favor of claim-based validation

Changes compared to main

  • OAuth2 configuration (src/phoenix/config.py)

    • Fixed default scopes to openid email profile.
    • New env: PHOENIX_OAUTH2_{IDP}_REQUIRED_GROUPS
      • Space-separated claim names you want the IdP to return (e.g., groups roles custom_claim).
      • Appended raw to the requested scope string.
      • Parsed into required_groups for server-side enforcement.
    • New env: PHOENIX_OAUTH2_{IDP}_TOKEN_ENDPOINT_AUTH_METHOD
      • Allowed: client_secret_basic (default), client_secret_post, none.
      • Propagated to the OAuth2 client for token exchange.
    • Existing env: PHOENIX_OAUTH2_{IDP}_CODE_CHALLENGE_METHOD
      • Allowed: S256 (recommended) or plain.
      • Enables PKCE (sends code_challenge + code_verifier).
    • Updated inline docs and env discovery to include these keys.
  • OAuth2 client (src/phoenix/server/oauth2.py)

    • Passes through:
      • scope from config,
      • required_groups (for downstream logic),
      • token_endpoint_auth_method when set,
      • code_challenge_method when set.
    • Uses built-in AsyncHttpxOAuth2Client with the above kwargs.
  • Router and auth flow (src/phoenix/server/api/routers/oauth2.py)

    • PKCE wired end-to-end: stores code_verifier in a cookie and sends it on token exchange.
    • parse_user_info: builds claims containing only non-empty values (filters out None, empty strings/containers).
    • Authorization now validates required claim keys (from required_groups) exist and are non-empty; otherwise raises SignInNotAllowed.
    • Removed strict “requested ⊆ granted” token scope check in favor of claim-based validation.
    • Removed debug logging.

New/updated environment variables

  • PHOENIX_OAUTH2_{IDP}_REQUIRED_GROUPS

    • Space-separated list of claim names the IdP should include in the ID token (e.g., groups roles team).
    • Appended to requested scopes as-is.
    • Sign-in requires each listed claim to exist and be non-empty.
  • PHOENIX_OAUTH2_{IDP}_TOKEN_ENDPOINT_AUTH_METHOD

    • Token endpoint client auth mode.
    • Allowed: client_secret_basic (default), client_secret_post, none.
  • PHOENIX_OAUTH2_{IDP}_CODE_CHALLENGE_METHOD

    • Enables PKCE when set.
    • Allowed: S256 (recommended) or plain.

Examples

# Ask IdP to include these claim keys and require them be non-empty
export PHOENIX_OAUTH2_GOOGLE_REQUIRED_GROUPS="groups roles team"

# Use client_secret_post to send client credentials in the token request body
export PHOENIX_OAUTH2_GOOGLE_TOKEN_ENDPOINT_AUTH_METHOD="client_secret_post"

# Enable PKCE with S256
export PHOENIX_OAUTH2_GOOGLE_CODE_CHALLENGE_METHOD="S256"

Note

Adds PKCE support and group-based access control to OAuth2/OIDC with new env-driven configuration, safer claim parsing, and comprehensive tests.

  • OAuth2/OIDC:
    • PKCE: End-to-end PKCE flow with code_verifier cookie; sends code_verifier on token exchange; cleans up cookies on success/error.
    • Claim Parsing: Stricter _parse_user_info with validation, normalized claims, and fallback to userinfo when needed.
    • Access Control: Group-based authorization via JMESPath (GROUPS_ATTRIBUTE_PATH) and ALLOWED_GROUPS; validates presence and membership.
    • Client: Supports TOKEN_ENDPOINT_AUTH_METHOD and PKCE (S256); extracts/normalizes groups; precompiles JMESPath; adds has_sufficient_claims and validate_access.
  • Configuration (src/phoenix/config.py):
    • Expanded OAuth2ClientConfig: optional client_secret (PKCE), use_pkce, token_endpoint_auth_method, scopes, groups_attribute_path, allowed_groups.
    • Robust env parsing/validation (HTTPS URLs, localhost exceptions, scopes deduplication, auth method options, groups requirements); broader env var discovery.
  • Cookies (src/phoenix/auth.py):
    • New constants and helpers to set/delete phoenix-oauth2-code-verifier cookie; cleanup on redirects.
  • Mock OIDC Server & Tests:
    • Server: PKCE (public/confidential), userinfo groups, discovery updates.
    • Integration/unit tests: PKCE flows, cookie security, CSRF/state checks, group access (granted/denied), JMESPath validation, config edge cases.
  • Dependencies:
    • Add jmespath and types-jmespath.

Written by Cursor Bugbot for commit 070e167. This will update automatically on new commits. Configure here.

@anderska anderska requested a review from a team as a code owner September 18, 2025 10:08
@github-project-automation github-project-automation bot moved this to 📘 Todo in phoenix Sep 18, 2025
@dosubot dosubot bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Sep 18, 2025
cursor[bot]

This comment was marked as outdated.

@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels Sep 18, 2025
@axiomofjoy axiomofjoy self-requested a review September 18, 2025 21:29
@axiomofjoy
Copy link
Contributor

Thanks @anderska for the contribution. The team is heads down on feature work and I'm not deeply familiar with PKSE, so it might take a minute, but we'll take a look.

@RogerHYang RogerHYang self-requested a review September 29, 2025 16:02
@RogerHYang RogerHYang moved this from 📘 Todo to 👨‍💻 In progress in phoenix Sep 29, 2025
@RogerHYang RogerHYang self-assigned this Sep 29, 2025
- USE_PKCE boolean with S256 hardcoded (replaces CODE_CHALLENGE_METHOD)
- Make CLIENT_SECRET optional for public PKCE clients
- GROUPS_ATTRIBUTE_PATH + ALLOWED_GROUPS with JMESPath for flexible claim extraction
- ID token -> userinfo fallback for missing claims
@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Oct 1, 2025
@RogerHYang
Copy link
Contributor

RogerHYang commented Oct 1, 2025

Thanks for your PKCE implementation! I've made several design changes that I wanted to explain:


1. CODE_CHALLENGE_METHODUSE_PKCE (boolean)

Your approach: Exposed CODE_CHALLENGE_METHOD as a user-configurable string (S256 or plain)

Changed to: Simple boolean USE_PKCE=true with S256 hardcoded

Rationale:

  • plain is deprecated and insecure (RFC 7636 strongly discourages it)
  • S256 is the only option anyone should use in production
  • Exposing the choice adds configuration complexity without real-world benefit

Also made CLIENT_SECRET optional when PKCE is enabled to properly support public clients.


2. REQUIRED_GROUPSGROUPS_ATTRIBUTE_PATH + ALLOWED_GROUPS

Your approach: Appended group names to the scopes parameter, checked for their presence as top-level claims

Changed to: Two-part system:

  • GROUPS_ATTRIBUTE_PATH: Path expression to extract groups from any claim structure
  • ALLOWED_GROUPS: Authorization list
  • (Variable names align with Grafana's convention for consistency)

Rationale:

The original approach had two fundamental issues:

  1. Groups aren't standardized in OIDC. Different IDPs may structure them completely differently.

  2. Appending to scopes doesn't control claim contents. Scopes like openid, profile, email are standard OIDC, but custom values like admin or developers aren't meaningful to the IDP. What claims are returned is configured at the IDP level, not requested via scopes.

The new approach uses a flexible path expression that works with any IDP's structure without needing IDP-specific code.


3. ID Token → UserInfo Fallback Strategy

Added: Conditional userinfo endpoint call with smart fallback

How it works:

  1. Parse ID token - if groups present, use them (no extra HTTP call)
  2. If groups missing, fetch from userinfo endpoint and merge
  3. If userinfo fails, fall back to ID token alone

Rationale:

Different IDPs have different behaviors:

  • AWS Cognito, Azure AD: Often omit groups from ID token to keep it small; require userinfo call
  • Okta, Auth0: Configurable - can be in either place
  • Keycloak: Usually in ID token by default

Let me know if you have questions about any of these decisions - happy to discuss further!

@RogerHYang RogerHYang changed the title Oauth2pkce feat: add PKCE for Oauth2 OIDC Oct 1, 2025
cursor[bot]

This comment was marked as outdated.

@RogerHYang RogerHYang moved this from 👨‍💻 In progress to 🔍. Needs Review in phoenix Oct 3, 2025
# and the code_verifier cookie exists
if oauth2_client.use_pkce and code_verifier:
fetch_kwargs["code_verifier"] = code_verifier
token_data = await oauth2_client.fetch_access_token(**fetch_kwargs)
Copy link

Choose a reason for hiding this comment

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

Bug: PKCE Code Verifier Missing Causes Token Rejection

When PKCE is enabled for an OAuth2 client, the code_verifier is only included in the token exchange if its cookie is present. If the cookie is missing, the request proceeds without the required verifier, causing the OIDC server to reject the token. This results in a generic 'oauth_error' for the user, obscuring the specific failure.

Fix in Cursor Fix in Web

"Group-based access control requires both parameters to be set."
)

self._compiled_groups_path = self._compile_jmespath_expression(self._groups_attribute_path)
Copy link

Choose a reason for hiding this comment

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

Bug: Initialization Error and Group Name Mismatch

The OAuth2Client initialization fails with an AttributeError when group-based access control is configured, as _groups_attribute_path is accessed before it's assigned. Additionally, group names in allowed_groups are not stripped of whitespace, which can lead to authorization mismatches with claims from the identity provider.

Fix in Cursor Fix in Web

"error_description": "code_verifier not allowed when PKCE is not enabled",
},
status_code=400,
)
Copy link

Choose a reason for hiding this comment

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

Bug: OIDC Server PKCE Validation Flaws

The mock OIDC server's /token endpoint has inconsistent code_verifier validation. In non-PKCE flows, code_verifier rejection happens after client authentication, potentially returning invalid_client instead of invalid_request and leaking information. The check also fails to reject empty code_verifier strings. When PKCE is enabled, invalid client credentials result in a generic invalid_client error.

Fix in Cursor Fix in Web

client_secret = _get_optional("CLIENT_SECRET")
else:
# Traditional flow: client_secret is required
client_secret = _get_required("CLIENT_SECRET", "Client secret")
Copy link

Choose a reason for hiding this comment

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

Bug: PKCE Misconfiguration Causes Security and Runtime Issues

Enabling use_pkce makes client_secret optional, which can lead to two issues. Confidential clients might unintentionally omit their secret, weakening their security posture. Additionally, if the token_endpoint_auth_method expects a secret (e.g., client_secret_basic), passing a None client_secret can cause runtime errors during token exchange.

Fix in Cursor Fix in Web

if g and g not in seen:
allowed_groups.append(g)
seen.add(g)

Copy link

Choose a reason for hiding this comment

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

Bug: Group Parsing Fails with Spaces

The ALLOWED_GROUPS environment variable is parsed in from_env using re.split(r"[,\s]+", raw_groups). This regex incorrectly splits group names containing spaces (e.g., 'admin users' becomes 'admin' and 'users'), which can lead to authorization bypasses. It also treats multiple consecutive delimiters as a single separator, potentially leading to unexpected parsing.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
size:XXL This PR changes 1000+ lines, ignoring generated files.
Projects
Status: 🔍. Needs Review
Development

Successfully merging this pull request may close these issues.

3 participants