Skip to content

fix(security): reject non-first-party tokens, stop ROLE_SERVICE inference (#312)#325

Open
dmytrocraft wants to merge 4 commits into
mainfrom
security/312-accesstoken-role-service-escalation
Open

fix(security): reject non-first-party tokens, stop ROLE_SERVICE inference (#312)#325
dmytrocraft wants to merge 4 commits into
mainfrom
security/312-accesstoken-role-service-escalation

Conversation

@dmytrocraft

@dmytrocraft dmytrocraft commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Security fix — Closes #312 (CRITICAL)

Vulnerability

AccessTokenValidator silently elevated any RS256 JWT lacking both iss and roles to ROLE_SERVICE, and skipped issuer/audience validation for that token class. The Lexik first-party JWT and the League OAuth2 server share one RSA keypair, so a League OAuth2 access token (aud=client_id, no iss/roles) passed signature verification and was promoted to ROLE_SERVICE — the sole gate for POST /api/users/batch (bulk user import). Any low-privilege OAuth2 client (client_credentials) or password-grant end user could therefore bulk-provision accounts. CWE-269 / CWE-287 / CWE-345.

Fix

  • Mandatory issuer/audience validation for every tokenvalidateClaims() calls validateIssuerAndAudience() unconditionally. League OAuth2 tokens (aud=client_id, no iss) are now rejected.
  • No privilege from absent claimsextractRoles() defaults a missing roles claim to least-privilege ROLE_USER; ROLE_SERVICE must be positively asserted in a signed first-party token (CWE-1188).
  • Defense-in-depth — explicit security: is_granted('ROLE_SERVICE') on the create_batch_http operation, so the restriction survives firewall misconfiguration.
  • Tests — flipped the unit test that encoded the vulnerable behaviour (testValidateReturnsServiceRoleForOauthTokenWithoutIsstestValidateRejectsOauthTokenWithoutIss) to assert rejection; added regression tests for OAuth2-shaped tokens, the api-audience-without-issuer case, the absent-roles→ROLE_USER default, and the still-valid explicit service token.

Compatibility

Legitimate first-party tokens (user and service) carry iss=vilnacrm-user-service, aud=vilnacrm-api, sid, and explicit roles (see TestAccessTokenFactory), so existing Behat/integration ROLE_SERVICE and ROLE_USER flows validate unchanged.

Local verification

  • Unit (validator/dual-authenticator/resolver): 118 passed
  • Deptrac: 0 violations · Psalm: no errors · php-cs-fixer: clean

BMAD

Spec bundle: specs/security-312-accesstoken-role-service-escalation/ (prd.md, stories.md) with FR/NFR + positive/negative/edge test mapping.

🤖 Generated with Claude Code


Summary by cubic

Fixes #312 by rejecting non‑first‑party JWTs and removing implicit ROLE_SERVICE. Adds a service‑role gate to bulk import to prevent OAuth2 token escalation.

  • Bug Fixes
    • Validate iss and aud for every token; reject tokens without iss or with non‑vilnacrm-api audience.
    • Never infer ROLE_SERVICE; missing roles defaults to ROLE_USER, explicit roles: null is rejected, and sid is required when roles is present.
    • Add security: "is_granted('ROLE_SERVICE')" to POST /api/users/batch for defense‑in‑depth.
    • Flip tests to assert secure behavior: OAuth2 client_credentials tokens now get 401 at the batch endpoint; add regressions for absent‑roles→ROLE_USER, api‑audience without issuer, and roles: null.

Written for commit cec441f. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Bulk user import endpoint now enforces explicit service-role authorization (defense-in-depth).
    • Access tokens are rejected unless issuer and audience are validated; tokens with roles must include a session id.
    • Missing roles now default to least-privileged role instead of inferring service privileges.
  • Documentation

    • Added PRD and stories detailing token validation and compatibility requirements.
  • Tests

    • Expanded unit and integration tests to cover new token validation and endpoint authorization behavior.

…ence (#312)

AccessTokenValidator silently elevated any RS256 JWT lacking both `iss` and
`roles` to ROLE_SERVICE and skipped issuer/audience validation for that token
class. Because the Lexik first-party JWT and the League OAuth2 server share a
single RSA keypair, a League OAuth2 access token (aud=client_id, no iss/roles)
passed signature verification and was promoted to ROLE_SERVICE, granting any
low-privilege client or end user access to POST /api/users/batch (bulk import).

- Validate issuer and audience unconditionally for every access token; tokens
  whose iss != vilnacrm-user-service or aud !contains vilnacrm-api are rejected.
- Default an absent roles claim to the least-privilege ROLE_USER; never infer
  ROLE_SERVICE from missing claims (CWE-1188 insecure default).
- Add explicit API Platform `security: is_granted('ROLE_SERVICE')` on the
  bulk-import operation as defense-in-depth.
- Flip the unit test that encoded the vulnerable behaviour to assert rejection;
  add regression tests for OAuth2-shaped tokens and the absent-roles case.

Closes #312

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR hardens JWT/OAuth2 token validation to prevent silent escalation to ROLE_SERVICE: it enforces unconditional issuer/audience checks, requires sid when roles are present, defaults missing roles to ROLE_USER, updates tests and integration expectations, and adds an API Platform operation-level ROLE_SERVICE guard for bulk-import.

Changes

Access Token Escalation Vulnerability Fix

Layer / File(s) Summary
Security Requirements & Implementation Plan
specs/security-312-accesstoken-role-service-escalation/prd.md, specs/security-312-accesstoken-role-service-escalation/stories.md
PRD and Stories define the vulnerability and require unconditional iss/aud validation, least-privilege defaulting when roles is missing, sid coupling with roles, and API-layer ROLE_SERVICE guard for bulk import.
Token Validator Core Changes
src/Shared/Infrastructure/Validator/AccessTokenValidator.php
validateClaims() now unconditionally validates issuer and audience, enforces session-binding (rejects tokens with roles but no sid); extractRoles() defaults to ['ROLE_USER'] when roles is absent.
Unit & Integration Test Updates
tests/Unit/Shared/Infrastructure/Validator/AccessTokenValidatorIssuerAudienceTest.php, tests/Integration/Auth/PasswordGrantIntegrationTest.php
Adds regression/unit tests rejecting tokens missing iss, verifies default to ROLE_USER when roles absent, accepts ROLE_SERVICE only with full first-party claims; integration test now expects 401 for client_credentials on protected bulk route.
API Platform Endpoint Defense-in-Depth
config/api_platform/resources/User.yaml
Adds security: "is_granted('ROLE_SERVICE')" to create_batch_http operation to enforce operation-level ROLE_SERVICE access control.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested Reviewers

  • Kravalg

Poem

🐰 A token hopped with furtive chance,
I checked the iss and aud at glance;
No roles? Now User stays the name,
Sid must bind if claims proclaim,
And batch imports stand locked at the gate. 🥕

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(security): reject non-first-party tokens, stop ROLE_SERVICE inference (#312)' is concise, specific, and accurately summarizes the main security fix and its primary objectives.
Linked Issues check ✅ Passed All six major requirements from issue #312 are addressed: mandatory issuer/audience validation (validateClaims → validateIssuerAndAudience), no privilege from absent claims (extractRoles defaults to ROLE_USER), defense-in-depth (explicit is_granted('ROLE_SERVICE')), revocation via session binding (sid requirement), role extraction fixes, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are narrowly scoped to the security fix: AccessTokenValidator, API Platform User.yaml, test suite updates, and specification documents—all directly addressing the #312 vulnerability with no unrelated modifications.
Description check ✅ Passed PR description is comprehensive and detailed, covering vulnerability details, fix rationale, compatibility notes, and verification results, closely aligned with the template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/312-accesstoken-role-service-escalation

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 and usage tips.

Signed-off-by: Vadym <kostiukdsfv@gmail.com>
cubic-dev-ai[bot]
cubic-dev-ai Bot previously approved these changes Jun 9, 2026

@cubic-dev-ai cubic-dev-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.

0 issues found across 1 file (changes from recent commits).

Re-trigger cubic

The integration test `testOauthClientCredentialsAccessTokenCanReachProtected
ServiceRoute` encoded the vulnerable behaviour — it asserted a League OAuth2
client_credentials access token COULD reach POST /api/users/batch. With the
ROLE_SERVICE escalation fixed, that token is now rejected with 401, so the
test is renamed and flipped to assert the secure outcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-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.

0 issues found across 1 file (changes from recent commits).

Re-trigger cubic

@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (fe53ccb) to head (cec441f).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #325   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          551       551           
  Lines         9541      9538    -3     
=========================================
- Hits          9541      9538    -3     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

cubic-dev-ai[bot]
cubic-dev-ai Bot previously approved these changes Jun 9, 2026

@cubic-dev-ai cubic-dev-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.

No issues found across 6 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.
Architecture diagram
sequenceDiagram
    participant Client as External Client
    participant Gateway as API Gateway / Firewall
    participant ApiPlatform as API Platform (User.yaml)
    participant Validator as AccessTokenValidator
    participant JwtDecoder as Lexik JWT Decoder
    participant Resolver as AccessTokenUserResolver
    participant Db as Database

    Note over Client,Db: NEW: Mandatory Issuer/Audience Validation Flow

    Client->>Gateway: POST /api/users/batch + Authorization: Bearer <token>
    Gateway->>Gateway: Extract JWT token from header
    
    alt Token is League OAuth2 token (aud=client_id, no iss)
        Gateway->>Validator: validate(token)
        Validator->>JwtDecoder: decode(token) → payload
        JwtDecoder-->>Validator: {sub, aud=client_id, exp, nbf}
        Validator->>Validator: validateClaims()
        Validator->>Validator: validateIssuerAndAudience()
        Note over Validator: Issuer missing → REJECTED
        Validator->>Gateway: Throw InvalidAccessTokenException
        Gateway-->>Client: 401 Unauthorized
    else Token is first-party with explicit roles
        Gateway->>Validator: validate(token)
        Validator->>JwtDecoder: decode(token) → payload
        JwtDecoder-->>Validator: {iss=vilnacrm-user-service, aud=vilnacrm-api, sid, roles=[ROLE_SERVICE]}
        Validator->>Validator: validateClaims()
        Validator->>Validator: validateIssuerAndAudience() ✓
        Validator->>Validator: validateSessionBinding() ✓
        Validator->>Validator: extractRoles()
        Note over Validator: roles present → use explicit roles
        Validator-->>Gateway: {subject, sid, roles=[ROLE_SERVICE]}
        Gateway->>Resolver: resolveUser(payload)
        Resolver-->>Gateway: User with ROLE_SERVICE
        Gateway->>ApiPlatform: Check is_granted('ROLE_SERVICE')
        ApiPlatform-->>Gateway: Granted
        Gateway->>Db: Execute batch import
        Db-->>Gateway: Users created
        Gateway-->>Client: 201 Created
    else Token has no roles claim (valid first-party)
        Gateway->>Validator: validate(token)
        Validator->>JwtDecoder: decode(token) → payload
        JwtDecoder-->>Validator: {iss=vilnacrm-user-service, aud=vilnacrm-api, sid}
        Validator->>Validator: validateClaims()
        Validator->>Validator: validateIssuerAndAudience() ✓
        Validator->>Validator: validateSessionBinding()
        Note over Validator: No roles claim → skip sid check
        Validator->>Validator: extractRoles()
        Note over Validator: roles absent → default to ROLE_USER
        Validator-->>Gateway: {subject, sid, roles=[ROLE_USER]}
        Gateway->>Resolver: resolveUser(payload)
        Resolver-->>Gateway: User with ROLE_USER
        Gateway->>ApiPlatform: Check is_granted('ROLE_SERVICE')
        ApiPlatform-->>Gateway: Denied
        Gateway-->>Client: 403 Forbidden
    else Token has roles but no sid
        Gateway->>Validator: validate(token)
        Validator->>JwtDecoder: decode(token) → payload
        JwtDecoder-->>Validator: {iss=vilnacrm-user-service, aud=vilnacrm-api, roles=[ROLE_SERVICE]}
        Validator->>Validator: validateClaims()
        Validator->>Validator: validateIssuerAndAudience() ✓
        Validator->>Validator: validateSessionBinding()
        Note over Validator: roles present but sid missing → REJECTED
        Validator->>Gateway: Throw InvalidAccessTokenException
        Gateway-->>Client: 401 Unauthorized
    end
Loading

Re-trigger cubic

@dmytrocraft

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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

🧹 Nitpick comments (1)
src/Shared/Infrastructure/Validator/AccessTokenValidator.php (1)

103-107: ⚡ Quick win

Remove inline explanatory comments from src/**/*.php.

Please replace these inline comments with self-explanatory naming/tests (or small method extraction), per repository rules for source files.

As per coding guidelines, src/**/*.php: “Remove inline comments; write self-explanatory code with clear naming. Extract helper methods instead of using comments for explanation.”

Also applies to: 216-219

🤖 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/Shared/Infrastructure/Validator/AccessTokenValidator.php` around lines
103 - 107, Replace the inline explanatory comments in AccessTokenValidator (the
block describing unconditional issuer/audience validation for every token) by
extracting the check into a small, well-named method and/or using
self-explanatory variable names so the behavior is clear without comments; e.g.,
create a method on AccessTokenValidator like
ensureFirstPartyTokenConstraintsOrValidateIssuerAndAudience() (or
validateIssuerAndAudienceForFirstPartyTokens()) and move the conditional logic
there, update any callers (e.g., validate or validateAccessToken) to use that
method, and add a unit test covering the League OAuth2 case (aud=client_id, no
iss) to preserve behavior — apply the same refactor for the other analogous
comment block in this class.

Source: Coding guidelines

🤖 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/Shared/Infrastructure/Validator/AccessTokenValidator.php`:
- Around line 115-118: The validateSessionBinding function currently uses
isset($payload['roles']) which treats an explicit roles: null as absent; change
the check to explicitly reject a roles key with null by using
array_key_exists('roles', $payload) and throwing
CustomUserMessageAuthenticationException when array_key_exists(...) &&
$payload['roles'] === null; also update the code paths that use the
($payload['roles'] ?? null) expression (lines where roles fallback is applied)
to distinguish "key missing" vs "key present but null" by using
array_key_exists('roles', $payload) to decide fallback to ROLE_USER only when
the key is truly absent, and to reject when the key exists but is null.

---

Nitpick comments:
In `@src/Shared/Infrastructure/Validator/AccessTokenValidator.php`:
- Around line 103-107: Replace the inline explanatory comments in
AccessTokenValidator (the block describing unconditional issuer/audience
validation for every token) by extracting the check into a small, well-named
method and/or using self-explanatory variable names so the behavior is clear
without comments; e.g., create a method on AccessTokenValidator like
ensureFirstPartyTokenConstraintsOrValidateIssuerAndAudience() (or
validateIssuerAndAudienceForFirstPartyTokens()) and move the conditional logic
there, update any callers (e.g., validate or validateAccessToken) to use that
method, and add a unit test covering the League OAuth2 case (aud=client_id, no
iss) to preserve behavior — apply the same refactor for the other analogous
comment block in this class.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8cee6846-98ad-4d26-8cd6-3fbc48fe8cde

📥 Commits

Reviewing files that changed from the base of the PR and between fe53ccb and ddb236b.

📒 Files selected for processing (6)
  • config/api_platform/resources/User.yaml
  • specs/security-312-accesstoken-role-service-escalation/prd.md
  • specs/security-312-accesstoken-role-service-escalation/stories.md
  • src/Shared/Infrastructure/Validator/AccessTokenValidator.php
  • tests/Integration/Auth/PasswordGrantIntegrationTest.php
  • tests/Unit/Shared/Infrastructure/Validator/AccessTokenValidatorIssuerAudienceTest.php

Comment thread src/Shared/Infrastructure/Validator/AccessTokenValidator.php
Reject explicit `roles: null` as malformed claims (CodeRabbit) by using
array_key_exists to distinguish a truly absent roles claim (defaults to
least-privilege ROLE_USER) from an explicit null/invalid one (rejected).
Remove inline explanatory comments per repository src/**/*.php convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dmytrocraft

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cubic-dev-ai cubic-dev-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.

0 issues found across 2 files (changes from recent commits).

Re-trigger cubic

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.

[Security] critical: OAuth2/JWT access tokens silently escalate to ROLE_SERVICE; issuer/audience validation skipped; session-revocation bypassed

2 participants