summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNejc Habjan <nejc.habjan@siemens.com>2022-01-19 23:05:47 +0100
committerNejc Habjan <nejc.habjan@siemens.com>2023-02-16 20:22:10 +0100
commitbe7745dc3dfee64d453287ed7d350adc7e5cadae (patch)
treed2c2134b539893c73b6c6ee31242f3b995e0eb16
parent1da7c53fd3476a1ce94025bb15265f674af40e1a (diff)
downloadgitlab-feat/oauth2-resource-password-flow.tar.gz
feat(client): replace basic auth with OAuth ROPC flowfeat/oauth2-resource-password-flow
-rw-r--r--docs/api-usage.rst28
-rw-r--r--docs/cli-usage.rst3
-rw-r--r--gitlab/client.py48
-rw-r--r--gitlab/oauth.py33
-rw-r--r--tests/functional/api/test_gitlab.py8
-rw-r--r--tests/unit/test_gitlab_auth.py63
-rw-r--r--tests/unit/test_oauth.py27
7 files changed, 181 insertions, 29 deletions
diff --git a/docs/api-usage.rst b/docs/api-usage.rst
index 2e7f5c6..d3af5ae 100644
--- a/docs/api-usage.rst
+++ b/docs/api-usage.rst
@@ -84,14 +84,32 @@ Note on password authentication
GitLab has long removed password-based basic authentication. You can currently still use the
`resource owner password credentials <https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow>`_
-flow to obtain an OAuth token.
+flow and python-gitlab will obtain an OAuth token for you when instantiated.
However, we do not recommend this as it will not work with 2FA enabled, and GitLab is removing
-ROPC-based flows without client IDs in a future release. We recommend you obtain tokens for
-automated workflows as linked above or obtain a session cookie from your browser.
+ROPC-based flows without client credentials in a future release. We recommend you obtain tokens for
+automated workflows.
-For a python example of password authentication using the ROPC-based OAuth2
-flow, see `this Ansible snippet <https://github.com/ansible-collections/community.general/blob/1c06e237c8100ac30d3941d5a3869a4428ba2974/plugins/module_utils/gitlab.py#L86-L92>`_.
+.. code-block:: python
+
+ import gitlab
+ from gitlab.oauth import PasswordCredentials
+
+ oauth_credentials = PasswordCredentials("username", "password")
+ gl = gitlab.Gitlab(oauth_credentials=oauth_credentials)
+
+ # Define a specific OAuth scope
+ oauth_credentials = PasswordCredentials("username", "password", scope="read_api")
+ gl = gitlab.Gitlab(oauth_credentials=oauth_credentials)
+
+ # Use with client credentials
+ oauth_credentials = PasswordCredentials(
+ "username",
+ "password",
+ client_id="your-client-id",
+ client_secret="your-client-secret",
+ )
+ gl = gitlab.Gitlab(oauth_credentials=oauth_credentials)
Managers
========
diff --git a/docs/cli-usage.rst b/docs/cli-usage.rst
index c728221..ee2627d 100644
--- a/docs/cli-usage.rst
+++ b/docs/cli-usage.rst
@@ -168,8 +168,7 @@ We recommend that you use `Credential helpers`_ to securely store your tokens.
<https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html>`__
to learn how to obtain a token.
* - ``oauth_token``
- - An Oauth token for authentication. The Gitlab server must be configured
- to support this authentication method.
+ - An Oauth token for authentication.
* - ``job_token``
- Your job token. See `the official documentation
<https://docs.gitlab.com/ce/api/jobs.html#get-job-artifacts>`__
diff --git a/gitlab/client.py b/gitlab/client.py
index c3982f3..0196a8f 100644
--- a/gitlab/client.py
+++ b/gitlab/client.py
@@ -12,7 +12,7 @@ import gitlab
import gitlab.config
import gitlab.const
import gitlab.exceptions
-from gitlab import _backends, utils
+from gitlab import _backends, oauth, utils
REDIRECT_MSG = (
"python-gitlab detected a {status_code} ({reason!r}) redirection. You must update "
@@ -41,8 +41,8 @@ class Gitlab:
the value is a string, it is the path to a CA file used for
certificate validation.
timeout: Timeout to use for requests to the GitLab server.
- http_username: Username for HTTP authentication
- http_password: Password for HTTP authentication
+ http_username: Username for OAuth ROPC flow (deprecated, use oauth_credentials)
+ http_password: Password for OAuth ROPC flow (deprecated, use oauth_credentials)
api_version: Gitlab API version to use (support for 4 only)
pagination: Can be set to 'keyset' to use keyset pagination
order_by: Set order_by globally
@@ -51,6 +51,7 @@ class Gitlab:
or 52x responses. Defaults to False.
keep_base_url: keep user-provided base URL for pagination if it
differs from response headers
+ oauth_credentials: Password credentials for authenticating via OAuth ROPC flow
Keyword Args:
requests.Session session: HTTP Requests Session
@@ -74,6 +75,8 @@ class Gitlab:
user_agent: str = gitlab.const.USER_AGENT,
retry_transient_errors: bool = False,
keep_base_url: bool = False,
+ *,
+ oauth_credentials: Optional[oauth.PasswordCredentials] = None,
**kwargs: Any,
) -> None:
self._api_version = str(api_version)
@@ -96,7 +99,7 @@ class Gitlab:
self.http_password = http_password
self.oauth_token = oauth_token
self.job_token = job_token
- self._set_auth_info()
+ self.oauth_credentials = oauth_credentials
#: Create a session object for requests
_backend: Type[_backends.DefaultBackend] = kwargs.pop(
@@ -105,6 +108,7 @@ class Gitlab:
self._backend = _backend(**kwargs)
self.session = self._backend.client
+ self._set_auth_info()
self.per_page = per_page
self.pagination = pagination
self.order_by = order_by
@@ -522,22 +526,48 @@ class Gitlab:
self.headers.pop("Authorization", None)
self.headers["PRIVATE-TOKEN"] = self.private_token
self.headers.pop("JOB-TOKEN", None)
+ return
+
+ if not self.oauth_credentials and (self.http_username and self.http_password):
+ utils.warn(
+ "Passing http_username and http_password is deprecated and will be "
+ "removed in a future version.\nPlease use the OAuth ROPC flow with"
+ "(gitlab.oauth.PasswordCredentials) if you need password-based"
+ "authentication. See https://docs.gitlab.com/ee/api/oauth2.html"
+ "#resource-owner-password-credentials-flow for more details.",
+ category=DeprecationWarning,
+ )
+ self.oauth_credentials = oauth.PasswordCredentials(
+ self.http_username, self.http_password
+ )
+
+ if self.oauth_credentials:
+ post_data = {
+ "grant_type": self.oauth_credentials.grant_type,
+ "scope": self.oauth_credentials.scope,
+ "username": self.oauth_credentials.username,
+ "password": self.oauth_credentials.password,
+ }
+ response = self.http_post(
+ f"{self._base_url}/oauth/token", post_data=post_data
+ )
+ if isinstance(response, dict):
+ self.oauth_token = response["access_token"]
+ else:
+ self.oauth_token = response.json()["access_token"]
+ self._http_auth = self.oauth_credentials.basic_auth
if self.oauth_token:
self.headers["Authorization"] = f"Bearer {self.oauth_token}"
self.headers.pop("PRIVATE-TOKEN", None)
self.headers.pop("JOB-TOKEN", None)
+ return
if self.job_token:
self.headers.pop("Authorization", None)
self.headers.pop("PRIVATE-TOKEN", None)
self.headers["JOB-TOKEN"] = self.job_token
- if self.http_username:
- self._http_auth = requests.auth.HTTPBasicAuth(
- self.http_username, self.http_password
- )
-
@staticmethod
def enable_debug() -> None:
import logging
diff --git a/gitlab/oauth.py b/gitlab/oauth.py
new file mode 100644
index 0000000..f4fecf8
--- /dev/null
+++ b/gitlab/oauth.py
@@ -0,0 +1,33 @@
+import dataclasses
+from typing import Optional
+
+
+@dataclasses.dataclass
+class PasswordCredentials:
+ """
+ Resource owner password credentials modelled according to
+ https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow
+ https://datatracker.ietf.org/doc/html/rfc6749#section-4-3.
+
+ If the GitLab server has disabled the ROPC flow without client credentials,
+ client_id and client_secret must be provided.
+ """
+
+ username: str
+ password: str
+ grant_type: str = "password"
+ scope: str = "api"
+ client_id: Optional[str] = None
+ client_secret: Optional[str] = None
+
+ def __post_init__(self) -> None:
+ basic_auth = (self.client_id, self.client_secret)
+
+ if not any(basic_auth):
+ self.basic_auth = None
+ return
+
+ if not all(basic_auth):
+ raise TypeError("Both client_id and client_secret must be defined")
+
+ self.basic_auth = basic_auth
diff --git a/tests/functional/api/test_gitlab.py b/tests/functional/api/test_gitlab.py
index ced77c2..bde64d3 100644
--- a/tests/functional/api/test_gitlab.py
+++ b/tests/functional/api/test_gitlab.py
@@ -2,6 +2,7 @@ import pytest
import requests
import gitlab
+from gitlab.oauth import PasswordCredentials
@pytest.fixture(
@@ -22,6 +23,13 @@ def test_auth_from_config(gl, gitlab_config, temp_dir):
assert isinstance(test_gitlab.user, gitlab.v4.objects.CurrentUser)
+def test_auth_with_ropc_flow(gl, temp_dir):
+ oauth_credentials = PasswordCredentials("root", "5iveL!fe")
+ test_gitlab = gitlab.Gitlab(gl.url, oauth_credentials=oauth_credentials)
+ test_gitlab.auth()
+ assert isinstance(test_gitlab.user, gitlab.v4.objects.CurrentUser)
+
+
def test_no_custom_session(gl, temp_dir):
"""Test no custom session"""
custom_session = requests.Session()
diff --git a/tests/unit/test_gitlab_auth.py b/tests/unit/test_gitlab_auth.py
index 8d6677f..3e0c87d 100644
--- a/tests/unit/test_gitlab_auth.py
+++ b/tests/unit/test_gitlab_auth.py
@@ -1,8 +1,35 @@
import pytest
-import requests
+import responses
from gitlab import Gitlab
from gitlab.config import GitlabConfigParser
+from gitlab.oauth import PasswordCredentials
+
+
+# /oauth/token endpoint might be missing correct content-type header
+@pytest.fixture(params=["application/json", None])
+def resp_oauth_token(gl: Gitlab, request: pytest.FixtureRequest):
+ ropc_payload = {
+ "username": "foo",
+ "password": "bar",
+ "grant_type": "password",
+ "scope": "api",
+ }
+ ropc_response = {
+ "access_token": "test-token",
+ "token_type": "bearer",
+ "expires_in": 7200,
+ }
+ with responses.RequestsMock() as rsps:
+ rsps.add(
+ method=responses.POST,
+ url=f"{gl._base_url}/oauth/token",
+ status=201,
+ match=[responses.matchers.json_params_matcher(ropc_payload)],
+ json=ropc_response,
+ content_type=request.param,
+ )
+ yield rsps
def test_invalid_auth_args():
@@ -42,7 +69,6 @@ def test_private_token_auth():
assert gl.private_token == "private_token"
assert gl.oauth_token is None
assert gl.job_token is None
- assert gl._http_auth is None
assert "Authorization" not in gl.headers
assert gl.headers["PRIVATE-TOKEN"] == "private_token"
assert "JOB-TOKEN" not in gl.headers
@@ -53,7 +79,6 @@ def test_oauth_token_auth():
assert gl.private_token is None
assert gl.oauth_token == "oauth_token"
assert gl.job_token is None
- assert gl._http_auth is None
assert gl.headers["Authorization"] == "Bearer oauth_token"
assert "PRIVATE-TOKEN" not in gl.headers
assert "JOB-TOKEN" not in gl.headers
@@ -64,26 +89,38 @@ def test_job_token_auth():
assert gl.private_token is None
assert gl.oauth_token is None
assert gl.job_token == "CI_JOB_TOKEN"
- assert gl._http_auth is None
assert "Authorization" not in gl.headers
assert "PRIVATE-TOKEN" not in gl.headers
assert gl.headers["JOB-TOKEN"] == "CI_JOB_TOKEN"
-def test_http_auth():
+def test_oauth_resource_password_auth(resp_oauth_token):
+ oauth_credentials = PasswordCredentials("foo", "bar")
gl = Gitlab(
"http://localhost",
- private_token="private_token",
- http_username="foo",
- http_password="bar",
api_version="4",
+ oauth_credentials=oauth_credentials,
)
- assert gl.private_token == "private_token"
- assert gl.oauth_token is None
+ assert gl.oauth_token == "test-token"
+ assert gl.private_token is None
assert gl.job_token is None
- assert isinstance(gl._http_auth, requests.auth.HTTPBasicAuth)
- assert gl.headers["PRIVATE-TOKEN"] == "private_token"
- assert "Authorization" not in gl.headers
+ assert "Authorization" in gl.headers
+ assert "PRIVATE-TOKEN" not in gl.headers
+
+
+def test_oauth_resource_password_auth_with_legacy_params_warns(resp_oauth_token):
+ with pytest.warns(DeprecationWarning, match="use the OAuth ROPC flow"):
+ gl = Gitlab(
+ "http://localhost",
+ http_username="foo",
+ http_password="bar",
+ api_version="4",
+ )
+ assert gl.oauth_token == "test-token"
+ assert gl.private_token is None
+ assert gl.job_token is None
+ assert "Authorization" in gl.headers
+ assert "PRIVATE-TOKEN" not in gl.headers
@pytest.mark.parametrize(
diff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py
new file mode 100644
index 0000000..ecc256b
--- /dev/null
+++ b/tests/unit/test_oauth.py
@@ -0,0 +1,27 @@
+import pytest
+
+from gitlab.oauth import PasswordCredentials
+
+
+def test_password_credentials_without_password_raises():
+ with pytest.raises(TypeError, match="missing 1 required positional argument"):
+ PasswordCredentials("username")
+
+
+def test_password_credentials_with_client_id_without_client_secret_raises():
+ with pytest.raises(TypeError, match="client_id and client_secret must be defined"):
+ PasswordCredentials(
+ "username",
+ "password",
+ client_id="abcdef123456",
+ )
+
+
+def test_password_credentials_with_client_credentials_sets_basic_auth():
+ credentials = PasswordCredentials(
+ "username",
+ "password",
+ client_id="abcdef123456",
+ client_secret="123456abcdef",
+ )
+ assert credentials.basic_auth == ("abcdef123456", "123456abcdef")