Skip to content

Commit 928310d

Browse files
Adding oauth security with existing basic PAT (#422)
* oauth addition * fixes
1 parent e09b943 commit 928310d

5 files changed

Lines changed: 290 additions & 8 deletions

File tree

sync2jira/downstream_issue.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
import Rover_Lookup
3838
from sync2jira.intermediary import Issue, PR
39+
from sync2jira.jira_auth import build_jira_client_kwargs
3940

4041
load_dotenv()
4142
# The date the service was upgraded
@@ -290,7 +291,8 @@ def get_jira_client(issue, config):
290291
log.error("No jira_instance for issue and there is no default in the config")
291292
raise Exception("No configured jira_instance for issue")
292293

293-
client = jira.client.JIRA(**config["sync2jira"]["jira"][jira_instance])
294+
client_kwargs = build_jira_client_kwargs(config["sync2jira"]["jira"][jira_instance])
295+
client = jira.client.JIRA(**client_kwargs)
294296
client.session() # This raises an exception if authentication was not successful
295297
return client
296298

sync2jira/jira_auth.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# This file is part of sync2jira.
2+
# Copyright (C) 2026 Red Hat, Inc.
3+
#
4+
# sync2jira is free software; you can redistribute it and/or
5+
# modify it under the terms of the GNU Lesser General Public
6+
# License as published by the Free Software Foundation; either
7+
# version 2.1 of the License, or (at your option) any later version.
8+
#
9+
# sync2jira is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12+
# Lesser General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Lesser General Public
15+
# License along with sync2jira; if not, write to the Free Software
16+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA
17+
18+
"""
19+
Jira authentication helpers.
20+
21+
This module's interface: pass a Jira instance config dict to
22+
:func:`build_jira_client_kwargs`; the config may include ``auth_method``
23+
(one of :const:`AUTH_METHOD_PAT` or :const:`AUTH_METHOD_OAUTH2`, default if
24+
omitted is :const:`AUTH_METHOD_PAT`), and credentials as described below.
25+
We ignore or remove config keys that do not apply to the chosen auth method
26+
and validate their values as needed.
27+
28+
- **PAT (Personal Access Token / API token)**: set ``auth_method`` to
29+
:const:`AUTH_METHOD_PAT` and provide ``basic_auth`` in the config.
30+
- **OAuth 2.0 2-Legged (2LO)** with Atlassian service account: set
31+
``auth_method`` to :const:`AUTH_METHOD_OAUTH2` and provide an ``oauth2``
32+
dict with ``client_id`` and ``client_secret``.
33+
"""
34+
35+
import logging
36+
import time
37+
from typing import Any, Dict, NamedTuple, Tuple
38+
39+
import requests
40+
41+
log = logging.getLogger("sync2jira")
42+
43+
# Default Atlassian OAuth 2.0 token endpoint (client credentials grant)
44+
DEFAULT_OAUTH2_TOKEN_URL = "https://auth.atlassian.com/oauth/token"
45+
46+
# Auth method config values
47+
AUTH_METHOD_PAT = "pat"
48+
AUTH_METHOD_OAUTH2 = "oauth2"
49+
50+
# Refresh token this many seconds before expiry (e.g. 5 min)
51+
OAUTH2_TOKEN_REFRESH_BUFFER_SECONDS = 300
52+
53+
54+
class OAuth2CachedToken(NamedTuple):
55+
"""OAuth2 access token and its expiry timestamp (seconds since epoch)."""
56+
57+
token: str
58+
expires_at: float
59+
60+
61+
# OAuth2 token cache: key (client_id, client_secret, token_url) -> OAuth2CachedToken.
62+
# Reused across syncs so we don't request a new token per issue/PR. No lock (single-threaded).
63+
_oauth2_token_cache: Dict[Tuple[str, str, str], OAuth2CachedToken] = {}
64+
65+
66+
def _fetch_oauth2_token(
67+
client_id: str,
68+
client_secret: str,
69+
token_url: str = DEFAULT_OAUTH2_TOKEN_URL,
70+
) -> OAuth2CachedToken:
71+
"""Request a new OAuth2 access token. Returns token and expiry timestamp."""
72+
response = requests.post(
73+
token_url,
74+
json={
75+
"grant_type": "client_credentials",
76+
"client_id": client_id,
77+
"client_secret": client_secret,
78+
},
79+
headers={"Content-Type": "application/json"},
80+
timeout=30,
81+
)
82+
response.raise_for_status()
83+
data = response.json()
84+
access_token = data.get("access_token")
85+
if not access_token:
86+
raise ValueError("OAuth 2.0 token response did not contain access_token")
87+
expires_in = int(data.get("expires_in", 3600))
88+
return OAuth2CachedToken(access_token, time.time() + expires_in)
89+
90+
91+
def _get_oauth2_token(
92+
client_id: str,
93+
client_secret: str,
94+
token_url: str = DEFAULT_OAUTH2_TOKEN_URL,
95+
) -> str:
96+
"""Return a valid OAuth2 token, reusing cache if not expired (with refresh buffer)."""
97+
key = (client_id, client_secret, token_url)
98+
now = time.time()
99+
if entry := _oauth2_token_cache.get(key):
100+
if now < entry.expires_at - OAUTH2_TOKEN_REFRESH_BUFFER_SECONDS:
101+
return entry.token
102+
cached = _fetch_oauth2_token(
103+
client_id=client_id,
104+
client_secret=client_secret,
105+
token_url=token_url,
106+
)
107+
_oauth2_token_cache[key] = cached
108+
return cached.token
109+
110+
111+
def build_jira_client_kwargs(jira_instance_config: Dict[str, Any]) -> Dict[str, Any]:
112+
"""
113+
Build keyword arguments for jira.client.JIRA() from a jira instance config.
114+
115+
:param jira_instance_config: One entry from config["sync2jira"]["jira"].
116+
:returns: Dict suitable for JIRA(**kwargs).
117+
:raises ValueError: If auth method is invalid or required keys are missing.
118+
"""
119+
# Copy so we don't mutate the original config
120+
kwargs = dict(jira_instance_config)
121+
122+
auth_method = kwargs.pop("auth_method", AUTH_METHOD_PAT)
123+
124+
if auth_method == AUTH_METHOD_OAUTH2:
125+
oauth2_cfg = kwargs.pop("oauth2", {}) or {}
126+
if not isinstance(oauth2_cfg, dict):
127+
raise ValueError("oauth2 must be a dict with client_id and client_secret")
128+
client_id = oauth2_cfg.get("client_id")
129+
client_secret = oauth2_cfg.get("client_secret")
130+
if not client_id or not client_secret:
131+
raise ValueError(
132+
"OAuth 2.0 (oauth2) auth requires oauth2.client_id and oauth2.client_secret"
133+
)
134+
token_url = oauth2_cfg.get("token_url", DEFAULT_OAUTH2_TOKEN_URL)
135+
kwargs.pop("basic_auth", None)
136+
try:
137+
access_token = _get_oauth2_token(
138+
client_id=client_id,
139+
client_secret=client_secret,
140+
token_url=token_url,
141+
)
142+
except requests.RequestException as e:
143+
log.error("OAuth 2.0 token request failed: %s", e)
144+
raise
145+
kwargs["token_auth"] = access_token
146+
return kwargs
147+
148+
if auth_method == AUTH_METHOD_PAT:
149+
# PAT: keep basic_auth and options as-is; remove oauth2
150+
kwargs.pop("oauth2", None)
151+
if "basic_auth" not in kwargs:
152+
raise ValueError("PAT auth requires basic_auth in the jira instance config")
153+
return kwargs
154+
155+
raise ValueError(
156+
f"Unsupported auth_method: {auth_method!r}. Use {AUTH_METHOD_PAT!r} or {AUTH_METHOD_OAUTH2!r}"
157+
)

tests/test_downstream_issue.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ def setUp(self):
3333
"jira_username": "mock_user",
3434
"default_jira_fields": {"storypoints": "customfield_12310243"},
3535
"jira": {
36-
"mock_jira_instance": {"mock_jira": "mock_jira"},
36+
"mock_jira_instance": {
37+
"basic_auth": ("email", "token"),
38+
"options": {"server": "mock_server"},
39+
},
3740
"another_jira_instance": {
38-
"token_auth": "mock_token",
41+
"basic_auth": ("email", "token"),
3942
"options": {"server": "mock_server"},
4043
},
4144
},
@@ -162,8 +165,10 @@ def test_get_jira_client(self, mock_client):
162165

163166
response = d.get_jira_client(issue=mock_issue, config=self.mock_config)
164167

165-
# Assert everything was called correctly
166-
mock_client.assert_called_with(mock_jira="mock_jira")
168+
# Assert everything was called correctly (kwargs from build_jira_client_kwargs)
169+
mock_client.assert_called_with(
170+
basic_auth=("email", "token"), options={"server": "mock_server"}
171+
)
167172
mock_jira_instance.session.assert_called_once()
168173
self.assertEqual(mock_jira_instance, response)
169174

@@ -184,7 +189,9 @@ def test_get_jira_client_auth_failure(self, mock_client):
184189
d.get_jira_client(issue=mock_issue, config=self.mock_config)
185190

186191
# Assert the client was created but failed authentication
187-
mock_client.assert_called_with(mock_jira="mock_jira")
192+
mock_client.assert_called_with(
193+
basic_auth=("email", "token"), options={"server": "mock_server"}
194+
)
188195
mock_jira_instance.session.assert_called_once()
189196

190197
@mock.patch("jira.client.JIRA")

tests/test_downstream_pr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def setUp(self):
3535
"jira": {
3636
"mock_jira_instance": {"mock_jira": "mock_jira"},
3737
"another_jira_instance": {
38-
"token_auth": "mock_token",
38+
"basic_auth": ("email", "mock_token"),
3939
"options": {"server": "mock_server"},
4040
},
4141
},

tests/test_main.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import unittest
22
import unittest.mock as mock
3-
from unittest.mock import MagicMock
3+
from unittest.mock import MagicMock, patch
44

5+
import requests
6+
7+
import sync2jira.jira_auth as jira_auth_module
8+
from sync2jira.jira_auth import build_jira_client_kwargs
59
import sync2jira.main as m
610

711
PATH = "sync2jira.main."
@@ -366,3 +370,115 @@ def test_handle_msg(self, mock_d, mock_u):
366370

367371
# Assert everything was called correctly
368372
mock_d.sync_with_jira.assert_called_with("dummy_issue", self.mock_config)
373+
374+
375+
class TestJiraAuth(unittest.TestCase):
376+
"""Tests for Jira auth: PAT and OAuth2 (build_jira_client_kwargs)."""
377+
378+
def setUp(self):
379+
"""Clear OAuth2 token cache so tests don't reuse tokens from other tests."""
380+
jira_auth_module._oauth2_token_cache.clear()
381+
382+
def test_jira_auth_pat_with_basic_auth(self):
383+
"""PAT with basic_auth succeeds."""
384+
config = {
385+
"options": {"server": "https://jira.example.com", "verify": True},
386+
"basic_auth": ("user", "pass"),
387+
}
388+
kwargs = build_jira_client_kwargs(config)
389+
self.assertEqual(kwargs["basic_auth"], ("user", "pass"))
390+
self.assertEqual(kwargs["options"], config["options"])
391+
392+
def test_jira_auth_pat_missing_basic_auth(self):
393+
"""PAT without basic_auth raises ValueError."""
394+
config = {
395+
"options": {"server": "https://jira.example.com"},
396+
}
397+
with self.assertRaises(ValueError) as ctx:
398+
build_jira_client_kwargs(config)
399+
self.assertIn("basic_auth", str(ctx.exception))
400+
401+
@patch("sync2jira.jira_auth.requests.post")
402+
def test_jira_auth_oauth2_cache(self, mock_post):
403+
"""OAuth2 second call reuses cached token (no second request)."""
404+
mock_post.return_value = MagicMock(
405+
status_code=200,
406+
json=lambda: {
407+
"access_token": "cached_token",
408+
"expires_in": 3600,
409+
},
410+
raise_for_status=MagicMock(),
411+
)
412+
config = {
413+
"options": {"server": "https://site.atlassian.net"},
414+
"auth_method": "oauth2",
415+
"oauth2": {"client_id": "cid", "client_secret": "csecret"},
416+
}
417+
kwargs1 = build_jira_client_kwargs(config)
418+
kwargs2 = build_jira_client_kwargs(config)
419+
self.assertEqual(kwargs1["token_auth"], "cached_token")
420+
self.assertEqual(kwargs2["token_auth"], "cached_token")
421+
mock_post.assert_called_once()
422+
423+
@patch("sync2jira.jira_auth.time.time")
424+
@patch("sync2jira.jira_auth.requests.post")
425+
def test_jira_auth_oauth2_refresh(self, mock_post, mock_time):
426+
"""OAuth2 expired token triggers new token fetch; second token is used."""
427+
mock_time.return_value = 1000.0
428+
429+
# Return different tokens per call so we can verify the second call's result is used
430+
def make_response(access_token, expires_in=60):
431+
return MagicMock(
432+
status_code=200,
433+
json=lambda t=access_token, e=expires_in: {
434+
"access_token": t,
435+
"expires_in": e,
436+
},
437+
raise_for_status=MagicMock(),
438+
)
439+
440+
mock_post.side_effect = [
441+
make_response("first_token"),
442+
make_response("refreshed_token"),
443+
]
444+
config = {
445+
"options": {"server": "https://site.atlassian.net"},
446+
"auth_method": "oauth2",
447+
"oauth2": {"client_id": "cid", "client_secret": "csecret"},
448+
}
449+
# First call populates cache (expires at 1000 + 60 = 1060)
450+
build_jira_client_kwargs(config)
451+
# Advance time past expiry (e.g. 2000)
452+
mock_time.return_value = 2000.0
453+
kwargs = build_jira_client_kwargs(config)
454+
self.assertEqual(kwargs["token_auth"], "refreshed_token")
455+
self.assertEqual(mock_post.call_count, 2)
456+
457+
def test_jira_auth_oauth2_missing_credentials(self):
458+
"""OAuth2 missing client_id or client_secret raises ValueError."""
459+
base = {
460+
"options": {"server": "https://site.atlassian.net"},
461+
"auth_method": "oauth2",
462+
}
463+
for oauth2_cfg in [
464+
{},
465+
{"client_id": "cid"},
466+
{"client_secret": "csecret"},
467+
]:
468+
config = base | {"oauth2": oauth2_cfg}
469+
with self.assertRaises(ValueError) as ctx:
470+
build_jira_client_kwargs(config)
471+
self.assertIn("client_id and oauth2.client_secret", str(ctx.exception))
472+
473+
@patch("sync2jira.jira_auth.requests.post")
474+
def test_jira_auth_oauth2_request_failure(self, mock_post):
475+
"""OAuth2 token request failure propagates requests.RequestException."""
476+
mock_post.side_effect = requests.RequestException("network error")
477+
config = {
478+
"options": {"server": "https://site.atlassian.net"},
479+
"auth_method": "oauth2",
480+
"oauth2": {"client_id": "cid", "client_secret": "csecret"},
481+
}
482+
with self.assertRaises(requests.RequestException) as ctx:
483+
build_jira_client_kwargs(config)
484+
self.assertIn("network error", str(ctx.exception))

0 commit comments

Comments
 (0)