Skip to content

Commit

Permalink
feat(client): replace basic auth with OAuth ROPC flow
Browse files Browse the repository at this point in the history
  • Loading branch information
nejch committed Feb 16, 2023
1 parent 1da7c53 commit be7745d
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 29 deletions.
28 changes: 23 additions & 5 deletions docs/api-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
========
Expand Down
3 changes: 1 addition & 2 deletions docs/cli-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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>`__
Expand Down
48 changes: 39 additions & 9 deletions gitlab/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
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 "
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -74,6 +75,8 @@ def __init__(
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)
Expand All @@ -96,7 +99,7 @@ def __init__(
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(
Expand All @@ -105,6 +108,7 @@ def __init__(
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
Expand Down Expand Up @@ -522,22 +526,48 @@ def _set_auth_info(self) -> None:
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
Expand Down
33 changes: 33 additions & 0 deletions gitlab/oauth.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions tests/functional/api/test_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import requests

import gitlab
from gitlab.oauth import PasswordCredentials


@pytest.fixture(
Expand All @@ -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()
Expand Down
63 changes: 50 additions & 13 deletions tests/unit/test_gitlab_auth.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_oauth.py
Original file line number Diff line number Diff line change
@@ -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")

0 comments on commit be7745d

Please sign in to comment.