feat(jira): Jira Cloud OAuth 2.0 (3LO) runtime — dual-mode alongside Server/DC#501
Conversation
…Server/DC Implements the runtime half of the Cloud plan from #416 (PR-2..4): - JiraCloudApiService (extends the OAuth1 monolith): 3LO authorize redirect, authorization-code exchange, rotating refresh tokens with recorded expiry (App ClockInterface — frozen-time safe), automatic cloudId resolution via accessible-resources, Bearer REST through api.atlassian.com/ex/jira/{cloudId}/rest/api/2/ (v2 keeps plain-text worklog comments), Cloud-only search/jql endpoint - CloudOAuthStateCodec: the ticket-system id rides encrypted inside the OAuth state (Cloud redirect URIs must match the registration exactly, so no ?tsid= like the OAuth1 flow), bound to the user, 10-min TTL - JiraOAuthApiFactory branches on DeploymentType — every existing caller (worklog subscriber, subticket sync, exports) gets the right transport transparently; Server/DC OAuth 1.0a is untouched - shared /jiraoauthcallback serves both flows (state => Cloud branch) - tokens stored encrypted at rest, tokensecret unused on Cloud Docs: ADR-017 marked implemented, Cloud app-registration walkthrough in configuration.md, README feature bullet. Verified: 21 new unit tests (state codec round-trip/tamper/expiry, authorize URL + decodable state, token rotation incl. rejected-refresh cleanup, cloudId host matching, search/jql payload); full unit suite 1575 green; PHPStan level 10, PHPat, PER-CS clean. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #501 +/- ##
============================================
- Coverage 84.15% 84.03% -0.13%
- Complexity 2814 2894 +80
============================================
Files 187 189 +2
Lines 7588 7819 +231
============================================
+ Hits 6386 6571 +185
- Misses 1202 1248 +46
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request implements Jira Cloud (OAuth 2.0 / 3LO) integration with rotating refresh tokens, adding the JiraCloudApiService and CloudOAuthStateCodec alongside corresponding unit tests and documentation. The code review identified a critical self-assignment bug in JiraOAuthCallbackAction where the token encryption service is assigned to itself instead of the method parameter, and suggested simplifying the controller's dependencies by injecting CloudOAuthStateCodec directly. Additionally, a potential runtime TypeError was pointed out in JiraCloudApiService due to DateTimeImmutable::modify() potentially returning false, which should be explicitly checked before setting the token expiration.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
Pull request overview
Adds runtime support for Jira Cloud alongside existing Jira Server/Data Center integration by introducing an OAuth 2.0 (3LO) transport/auth layer and routing the callback/factory based on ticket-system deployment type.
Changes:
- Introduces
JiraCloudApiService(OAuth2 3LO + rotating refresh tokens, cloudId resolution, gateway REST v2, Cloudsearch/jql) while reusing the base Jira API operations. - Branches
JiraOAuthApiFactorybyDeploymentTypeand updates the shared/jiraoauthcallbackcontroller to handle both OAuth1 (Server/DC) and OAuth2 (Cloud). - Adds unit tests for the new Cloud OAuth state codec, token lifecycle, cloudId matching, and factory branching; updates docs/README/ADR accordingly.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Service/Integration/Jira/JiraOAuthApiFactoryTest.php | Updates factory tests to cover Server vs Cloud service selection and the new clock dependency. |
| tests/Service/Integration/Jira/JiraCloudApiServiceTest.php | Adds unit tests covering Cloud OAuth2 authorize/exchange/refresh, cloudId resolution, and Cloud-specific REST behavior. |
| tests/Service/Integration/Jira/CloudOAuthStateCodecTest.php | Adds tests for encrypted OAuth2 state encoding/decoding, tamper resistance, and TTL expiry. |
| src/Service/Integration/Jira/JiraOAuthApiService.php | Makes decryptStored() overridable for Cloud subclass token handling. |
| src/Service/Integration/Jira/JiraOAuthApiFactory.php | Adds DeploymentType branching and injects ClockInterface for Cloud token expiry logic. |
| src/Service/Integration/Jira/JiraCloudApiService.php | New Cloud implementation: 3LO flow, rotating refresh tokens, gateway REST v2, cloudId lookup, and search/jql. |
| src/Service/Integration/Jira/CloudOAuthStateCodec.php | New encrypted, TTL-bound OAuth2 state codec carrying user + ticket-system identity. |
| src/Controller/Default/JiraOAuthCallbackAction.php | Extends callback route to handle both OAuth1 (Server/DC) and OAuth2 (Cloud) callbacks. |
| README.md | Updates Jira integration feature statement to include Cloud (OAuth2/3LO). |
| docs/configuration.md | Documents Jira Cloud app registration (scopes + exact callback URL) and user authorization flow. |
| docs/adr/ADR-017-jira-cloud-oauth2.md | Updates ADR status/record to reflect the implemented runtime mechanism. |
- JiraOAuthCallbackAction gets CloudOAuthStateCodec injected directly instead of assembling it from TokenEncryptionService + Clock (Gemini) - #[Override] attributes and a native never return type on the Cloud service's overrides (Rector gate that CI runs but my local gate list missed — now part of it) Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
- read the access token through the repository-loaded row instead of the User collection (single source of truth; survives first-auth and rotation in the same process) - only a definitive 400/401/403 from the token endpoint clears the grant and restarts authorization; transient failures (network, 5xx) rethrow and keep the rotating refresh token for the next attempt (+ regression test) - cache the tenant REST client in one slot rebuilt on rotation instead of accumulating per-token entries - authorization-denied callback answers 400 instead of 200 Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
|



Summary
Implements the runtime half of the Jira Cloud plan started in #416 ("PR-2..4" of the original series): TimeTracker now syncs worklogs against both Jira Server/DC (OAuth 1.0a, unchanged) and Jira Cloud (OAuth 2.0 authorization-code / 3LO), selected per ticket system via the deployment type that #416 introduced.
How it works
JiraCloudApiServiceextends the existing service and swaps only the transport/auth layer: 3LO authorize redirect, authorization-code exchange, rotating refresh tokens with recorded expiry (via the app'sClockInterface, frozen-time safe), automatic cloudId resolution fromaccessible-resources(matched by site host), Bearer-authenticated REST throughapi.atlassian.com/ex/jira/{cloudId}/rest/api/2/, and the Cloud-onlysearch/jqlendpoint. All worklog/issue/subticket operations of the base class run unchanged on top.CloudOAuthStateCodec: Cloud redirect URIs must match the registered URL exactly, so the ticket-system id cannot ride as?tsid=like in the OAuth1 flow — it travels inside the encrypted, user-bound, 10-minute-TTLstateparameter instead.JiraOAuthApiFactorybranches onDeploymentType— every existing caller (worklog subscriber, subticket sync, exports, admin sync) gets the right transport transparently./jiraoauthcallbackroute serves both flows (astateparameter selects the Cloud branch;error=access_deniedis reported cleanly).Docs: ADR-017 updated to "implemented" with the mechanism recorded; Cloud app-registration walkthrough (scopes, exact callback URL) added to
docs/configuration.md; README feature bullet updated.Context: the Mogic fork's Cloud adaptation (mogic-le/timetracker, TIM-130) confirmed
search/jqlas the required Cloud search endpoint; their fork still uses deprecated OAuth 1.0a against Cloud — this PR provides the supported 3LO path instead.Verification
search/jqlpayload, OAuth1-callback rejection, bare callback URLOut of scope
JiraIntegrationServiceetc.) remain excluded from DI, unchanged