8000 feat(client): replace basic auth with OAuth ROPC flow · python-gitlab/python-gitlab@7b1a20d · GitHub
[go: up one dir, main page]

Skip to content

Commit 7b1a20d

Browse files
committed
feat(client): replace basic auth with OAuth ROPC flow
1 parent c7cf0d1 commit 7b1a20d

File tree

7 files changed

+182
-30
lines changed

7 files changed

+182
-30
lines changed

docs/api-usage.rst

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,32 @@ Note on password authentication
8484

8585
GitLab has long removed password-based basic authentication. You can currently still use the
8686
`resource owner password credentials <https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow>`_
87-
flow to obtain an OAuth token.
87+
flow and python-gitlab will obtain an OAuth token for you when instantiated.
8888

8989
However, we do not recommend this as it will not work with 2FA enabled, and GitLab is removing
90-
ROPC-based flows without client IDs in a future release. We recommend you obtain tokens for
91-
automated workflows as linked above or obtain a session cookie from your browser.
90+
ROPC-based flows without client credentials in a future release. We recommend you obtain tokens for
91+
automated workflows.
9292

93-
For a python example of password authentication using the ROPC-based OAuth2
94-
flow, see `this Ansible snippet <https://github.com/ansible-collections/community.general/blob/1c06e237c8100ac30d3941d5a3869a4428ba2974/plugins/module_utils/gitlab.py#L86-L92>`_.
93+
.. code-block:: python
94+
95+
import gitlab
96+
from gitlab.oauth import PasswordCredentials
97+
98+
oauth_credentials = PasswordCredentials("username", "password")
99+
gl = gitlab.Gitlab(oauth_credentials=oauth_credentials)
100+
101+
# Define a specific OAuth scope
102+
oauth_credentials = PasswordCredentials("username", "password", scope="read_api")
103+
gl = gitlab.Gitlab(oauth_credentials=oauth_credentials)
104+
105+
# Use with client credentials
106+
oauth_credentials = PasswordCredentials(
107+
"username",
108+
"password",
109+
client_id="your-client-id",
110+
client_secret="your-client-secret",
111+
)
112+
gl = gitlab.Gitlab(oauth_credentials=oauth_credentials)
95113
96114
Managers
97115
========

docs/cli-usage.rst

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,7 @@ We recommend that you use `Credential helpers`_ to securely store your tokens.
168168
<https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html>`__
169169
to learn how to obtain a token.
170170
* - ``oauth_token``
171-
- An Oauth token for authentication. The Gitlab server must be configured
172-
to support this authentication method.
171+
- An Oauth token for authentication.
173172
* - ``job_token``
174173
- Your job token. See `the official documentation
175174
<https://docs.gitlab.com/ce/api/jobs.html#get-job-artifacts>`__

gitlab/client.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import gitlab.config
1515
import gitlab.const
1616
import gitlab.exceptions
17-
from gitlab import http_backends, utils
17+
from gitlab import http_backends, oauth, utils
1818

1919
REDIRECT_MSG = (
2020
"python-gitlab detected a {status_code} ({reason!r}) redirection. You must update "
@@ -43,8 +43,8 @@ class Gitlab:
4343
the value is a string, it is the path to a CA file used for
4444
certificate validation.
4545
timeout: Timeout to use for requests to the GitLab server.
46-
http_username: Username for HTTP authentication
47-
http_password: Password for HTTP authentication
46+
http_username: Username for OAuth ROPC flow (deprecated, use oauth_credentials)
47+
http_password: Password for OAuth ROPC flow (deprecated, use oauth_credentials)
4848
api_version: Gitlab API version to use (support for 4 only)
4949
pagination: Can be set to 'keyset' to use keyset pagination
5050
order_by: Set order_by globally
@@ -53,6 +53,7 @@ class Gitlab:
5353
or 52x responses. Defaults to False.
5454
keep_base_url: keep user-provided base URL for pagination if it
5555
differs from response headers
56+
oauth_credentials: Password credentials for authenticating via OAuth ROPC flow
5657
5758
Keyward Args:
5859
requests.Session session: Http Requests Session
@@ -76,6 +77,8 @@ def __init__(
7677
user_agent: str = gitlab.const.USER_AGENT,
7778
retry_transient_errors: bool = False,
7879
keep_base_url: bool = False,
80+
*,
81+
oauth_credentials: Optional[oauth.PasswordCredentials] = None,
7982
**kwargs: Any,
8083
) -> None:
8184
self._api_version = str(api_version)
@@ -98,13 +101,15 @@ def __init__(
98101
self.http_password = http_password
99102
self.oauth_token = oauth_token
100103
self.job_token = job_token
101-
self._set_auth_info()
104+
self.oauth_credentials = oauth_credentials
102105

103106
#: Create a session object for requests
104107
http_backend: Type[http_backends.DefaultBackend] = kwargs.pop(
105108
"http_backend", http_backends.DefaultBackend
106109
)
107110
self.http_backend = http_backend(**kwargs)
111+
112+
self._set_auth_info()
108113
self.session = self.http_backend.client
109114

110115
self.per_page = per_page
@@ -514,28 +519,53 @@ def _set_auth_info(self) -> None:
514519
"Only one of oauth authentication or http "
515520
"authentication should be defined"
516521
)
517-
518522
self._http_auth = None
519523
if self.private_token:
520524
self.headers.pop("Authorization", None)
521525
self.headers["PRIVATE-TOKEN"] = self.private_token
522526
self.headers.pop("JOB-TOKEN", None)
527+
return
528+
529+
if not self.oauth_credentials and (self.http_username and self.http_password):
530+
utils.warn(
531+
"Passing http_username and http_password is deprecated and will be "
532+
"removed in a future version.\nPlease use the OAuth ROPC flow with"
533+
"(gitlab.oauth.PasswordCredentials) if you need password-based"
534+
"authentication. See https://docs.gitlab.com/ee/api/oauth2.html"
535+
"#resource-owner-password-credentials-flow for more details.",
536+
category=DeprecationWarning,
537+
)
538+
self.oauth_credentials = oauth.PasswordCredentials(
539+
self.http_username, self.http_password
540+
)
541+
542+
if self.oauth_credentials:
543+
post_data = {
544+
"grant_type": self.oauth_credentials.grant_type,
545+
"scope": self.oauth_credentials.scope,
546+
"username": self.oauth_credentials.username,
547+
"password": self.oauth_credentials.password,
548+
}
549+
response = self.http_post(
550+
f"{self._base_url}/oauth/token", post_data=post_data
551+
)
552+
if isinstance(response, dict):
553+
self.oauth_token = response["access_token"]
554+
else:
555+
self.oauth_token = response.json()["access_token"]
556+
self._http_auth = self.oauth_credentials.basic_auth
523557

524558
if self.oauth_token:
525559
self.headers["Authorization"] = f"Bearer {self.oauth_token}"
526560
self.headers.pop("PRIVATE-TOKEN", None)
527561
self.headers.pop("JOB-TOKEN", None)
562+
return
528563

529564
if self.job_token:
530565
self.headers.pop("Authorization", None)
531566
self.headers.pop("PRIVATE-TOKEN", None)
532567
self.headers["JOB-TOKEN"] = self.job_token
533568

534-
if self.http_username:
535-
self._http_auth = requests.auth.HTTPBasicAuth(
536-
self.http_username, self.http_password
537-
)
538-
539569
@staticmethod
540570
def enable_debug() -> None:
541571
import logging

gitlab/oauth.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import dataclasses
2+
from typing import Optional
3+
4+
5+
@dataclasses.dataclass
6+
class PasswordCredentials:
7+
"""
8+
Resource owner password credentials modelled according to
9+
https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow
10+
https://datatracker.ietf.org/doc/html/rfc6749#section-4-3.
11+
12+
If the GitLab server has disabled the ROPC flow without client credentials,
13+
client_id and client_secret must be provided.
14+
"""
15+
16+
username: str
17+
password: str
18+
grant_type: str = "password"
19+
scope: str = "api"
20+
client_id: Optional[str] = None
21+
client_secret: Optional[str] = None
22+
23+
def __post_init__(self) -> None:
24+
basic_auth = (self.client_id, self.client_secret)
25+
26+
if not any(basic_auth):
27+
self.basic_auth = None
28+
return
29+
30+
if not all(basic_auth):
31+
raise TypeError("Both client_id and client_secret must be defined")
32+
33+
self.basic_auth = basic_auth

tests/functional/api/test_gitlab.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import requests
33

44
import gitlab
5+
from gitlab.oauth import PasswordCredentials
56

67

78
@pytest.fixture F438 (
@@ -24,6 +25,13 @@ def test_auth_from_config(gl, temp_dir):
2425
assert isinstance(test_gitlab.user, gitlab.v4.objects.CurrentUser)
2526

2627

28+
def test_auth_with_ropc_flow(gl, temp_dir):
29+
oauth_credentials = PasswordCredentials("root", "5iveL!fe")
30+
test_gitlab = gitlab.Gitlab(gl.url, oauth_credentials=oauth_credentials)
31+
test_gitlab.auth()
32+
assert isinstance(test_gitlab.user, gitlab.v4.objects.CurrentUser)
33+
34+
2735
def test_no_custom_session(gl, temp_dir):
2836
"""Test no custom session"""
2937
custom_session = requests.Session()

tests/unit/test_gitlab_auth.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
11
import pytest
2-
import requests
2+
import responses
33

44
from gitlab import Gitlab
55
from gitlab.config import GitlabConfigParser
6+
from gitlab.oauth import PasswordCredentials
7+
8+
9+
# /oauth/token endpoint might be missing correct content-type header
10+
@pytest.fixture(params=["application/json", None])
11+
def resp_oauth_token(gl: Gitlab, request: pytest.FixtureRequest):
12+
ropc_payload = {
13+
"username": "foo",
14+
"password": "bar",
15+
"grant_type": "password",
16+
"scope": "api",
17+
}
18+
ropc_response = {
19+
"access_token": "test-token",
20+
"token_type": "bearer",
21+
"expires_in": 7200,
22+
}
23+
with responses.RequestsMock() as rsps:
24+
rsps.add(
25+
method=responses.POST,
26+
url=f"{gl._base_url}/oauth/token",
27+
status=201,
28+
match=[responses.matchers.json_params_matcher(ropc_payload)],
29+
json=ropc_response,
30+
content_type=request.param,
31+
)
32+
yield rsps
633

734

835
def test_invalid_auth_args():
@@ -42,7 +69,6 @@ def test_private_token_auth():
4269
assert gl.private_token == "private_token"
4370
assert gl.oauth_token is None
4471
assert gl.job_token is None
45-
assert gl._http_auth is None
4672
assert "Authorization" not in gl.headers
4773
assert gl.headers["PRIVATE-TOKEN"] == "private_token"
4874
assert "JOB-TOKEN" not in gl.headers
@@ -53,7 +79,6 @@ def test_oauth_token_auth():
5379
assert gl.private_token is None
5480
assert gl.oauth_token == "oauth_token"
5581
assert gl.job_token is None
56-
assert gl._http_auth is None
5782
assert gl.headers["Authorization"] == "Bearer oauth_token"
5883
assert "PRIVATE-TOKEN" not in gl.headers
5984
assert "JOB-TOKEN" not in gl.headers
@@ -64,26 +89,38 @@ def test_job_token_auth():
6489
assert gl.private_token is None
6590
assert gl.oauth_token is None
6691
assert gl.job_token == "CI_JOB_TOKEN"
67-
assert gl._http_auth is None
6892
assert "Authorization" not in gl.headers
6993
assert "PRIVATE-TOKEN" not in gl.headers
7094
assert gl.headers["JOB-TOKEN"] == "CI_JOB_TOKEN"
7195

7296

73-
def test_http_auth():
97+
def test_oauth_resource_password_auth(resp_oauth_token):
98+
oauth_credentials = PasswordCredentials("foo", "bar")
7499
gl = Gitlab(
75100
"http://localhost",
76-
private_token="private_token",
77-
http_username="foo",
78-
http_password="bar",
79101
api_version="4",
102+
oauth_credentials=oauth_credentials,
80103
)
81-
assert gl.private_token == "private_token"
82-
assert gl.oauth_token is None
104+
assert gl.oauth_token == "test-token"
105+
assert gl.private_token is None
83106
assert gl.job_token is None
84-
assert isinstance(gl._http_auth, requests.auth.HTTPBasicAuth)
85-
assert gl.headers["PRIVATE-TOKEN"] == "private_token"
86-
assert "Authorization" not in gl.headers
107+
assert "Authorization" in gl.headers
108+
assert "PRIVATE-TOKEN" not in gl.headers
109+
110+
111+
def test_oauth_resource_password_auth_with_legacy_params_warns(resp_oauth_token):
112+
with pytest.warns(DeprecationWarning, match="use the OAuth ROPC flow"):
113+
gl = Gitlab(
114+
"http://localhost",
115+
http_username="foo",
116+
http_password="bar",
117+
api_version="4",
118+
)
119+
assert gl.oauth_token == "test-token"
120+
assert gl.private_token is None
121+
assert gl.job_token is None
122+
assert "Authorization" in gl.headers
123+
assert "PRIVATE-TOKEN" not in gl.headers
87124

88125

89126
@pytest.mark.parametrize(

tests/unit/test_oauth.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
3+
from gitlab.oauth import PasswordCredentials
4+
5+
6+
def test_password_credentials_without_password_raises():
7+
with pytest.raises(TypeError, match="missing 1 required positional argument"):
8+
PasswordCredentials("username")
9+
10+
11+
def test_password_credentials_with_client_id_without_client_secret_raises():
12+
with pytest.raises(TypeError, match="client_id and client_secret must be defined"):
13+
PasswordCredentials(
14+
"username",
15+
"password",
16+
client_id="abcdef123456",
17+
)
18+
19+
20+
def test_password_credentials_with_client_credentials_sets_basic_auth():
21+
credentials = PasswordCredentials(
22+
"username",
23+
"password",
24+
client_id="abcdef123456",
25+
client_secret="123456abcdef",
26+
)
27+
assert credentials.basic_auth == ("abcdef123456", "123456abcdef")

0 commit comments

Comments
 (0)
0