|
| 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 | + ) |
0 commit comments