Skip to content
Snippets Groups Projects
Commit f3d89ebc authored by Antoine Lambert's avatar Antoine Lambert
Browse files

auth: Simplify authentication after changes in swh-web

Token renewal workflow is now delegated to swh-web for commodity of use when
a user wants to make authenticated API calls.

So remove no more needed code and update documentation regarding authentication.
parent ca947d48
No related branches found
No related tags found
1 merge request!7auth: Simplify authentication after changes in swh-web
......@@ -9,10 +9,10 @@ Authentication
If you have a user account registered on `Software Heritage Identity Provider`_,
it is possible to authenticate requests made to the Web APIs through the use of
OpenID Connect bearer tokens. Sending authenticated requests can notably
a OpenID Connect bearer token. Sending authenticated requests can notably
allow to lift API rate limiting depending on your permissions.
To get these tokens, a dedicated CLI tool is made available when installing
To get this token, a dedicated CLI tool is made available when installing
``swh-web-client``:
.. code-block:: text
......@@ -37,70 +37,41 @@ To get these tokens, a dedicated CLI tool is made available when installing
Commands:
login Login and create new offline OpenID Connect session.
logout Logout from an offline OpenID Connect session.
refresh Refresh an offline OpenID Connect session.
In order to get your tokens, you need to use the ``login`` subcommand of
that CLI tool by passing your username as argument. You will be prompted
for your password and if the authentication succeeds a new OpenID Connect
session will be created and tokens will be dumped in JSON format to standard
output.
session will be created and tokens will be dumped to standard output.
.. code-block:: text
$ swh auth login <username>
Password:
{
"access_token": ".......",
"expires_in": 600,
"refresh_expires_in": 0,
"refresh_token": ".......",
"token_type": "bearer",
"id_token": ".......",
"not-before-policy": 1584551170,
"session_state": "c14e1b7b-8263-4852-bd1c-adc7bc12a136",
"scope": "openid email profile offline_access"
}
To authenticate yourself, you need to send the ``access_token`` value in
request headers when querying the Web APIs.
Considering you have stored the ``access_token`` value in a TOKEN environment
eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJmNjMzMD...
To authenticate yourself, you need to send that token value in request headers
when querying the Web API.
Considering you have stored that token value in a TOKEN environment
variable, you can perform an authenticated call the following way using ``curl``:
.. code-block:: text
$ curl -H "Authorization: Bearer ${TOKEN}" https://archive.softwareheritage.org/api/1/<endpoint>
The access token has a short living period (usually ten minutes) and must be
renewed on a regular basis by passing the ``refresh_token`` value as argument
of the ``refresh`` subcommand of the CLI tool. The new access token will be
dumped in JSON format to standard output. Note that the refresh token has a
much longer living period (usually several dozens of days) so you can use
it anytime while it is valid to get an access token without having to login
again.
.. code-block:: text
$ swh auth refresh $REFRESH_TOKEN
"......."
Note that if you intend to use the :class:`swh.web.client.client.WebAPIClient`
class, the access token renewal will be automatically handled if you call
method :meth:`swh.web.client.client.WebAPIClient.authenticate` prior to
sending any requests. To activate authentication, use the following code snippet::
class, you can activate authentication by using the following code snippet::
from swh.web.client import WebAPIClient
REFRESH_TOKEN = '.......' # Use "swh auth login" command to get it
TOKEN = '.......' # Use "swh auth login" command to get it
client = WebAPIClient()
client.authenticate(REFRESH_TOKEN)
client = WebAPIClient(bearer_token=TOKEN)
# All requests to the Web API will be authenticated
resp = client.get('swh:1:rev:aafb16d69fd30ff58afdd69036a26047f3aebdc6')
It is also possible to ``logout`` from the authenticated OpenID Connect session
which invalidates all previously emitted tokens.
which definitely revokes the token.
.. code-block:: text
......
......@@ -16,7 +16,7 @@ SWH_WEB_CLIENT_ID = "swh-web"
class AuthenticationError(Exception):
"""Authentication related error.
Example: A bearer token has expired.
Example: A bearer token has been revoked.
"""
......@@ -53,8 +53,7 @@ class OpenIDConnectSession:
password: password associated to username
Returns:
a dict filled with OpenID Connect profile info, notably access
and refresh tokens for API authentication.
The OpenID Connect session info
"""
return requests.post(
url=self.token_url,
......@@ -67,40 +66,19 @@ class OpenIDConnectSession:
},
).json()
def refresh(self, refresh_token: str) -> Dict[str, Any]:
"""
Refresh an offline OpenID Connect session to get new access token.
Args:
refresh_token: a refresh token retrieved after login
Returns:
a dict filled with OpenID Connect profile info, notably access
and refresh tokens for API authentication.
"""
return requests.post(
url=self.token_url,
data={
"grant_type": "refresh_token",
"client_id": self.client_id,
"scope": "openid",
"refresh_token": refresh_token,
},
).json()
def logout(self, refresh_token: str):
def logout(self, token: str):
"""
Logout from an offline OpenID Connect session and invalidate
previously emitted tokens.
Args:
refresh_token: a refresh token retrieved after login
token: a bearer token retrieved after login
"""
requests.post(
url=self.logout_url,
data={
"client_id": self.client_id,
"scope": "openid",
"refresh_token": refresh_token,
"refresh_token": token,
},
)
......@@ -4,7 +4,6 @@
# See top-level LICENSE file for more information
from getpass import getpass
import json
import click
from click.core import Context
......@@ -14,10 +13,6 @@ from swh.web.client.auth import OpenIDConnectSession
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def _output_json(obj):
print(json.dumps(obj, indent=4, sort_keys=True))
@click.group(name="auth", context_settings=CONTEXT_SETTINGS)
@click.option(
"--oidc-server-url",
......@@ -48,7 +43,7 @@ def auth(ctx: Context, oidc_server_url: str, realm_name: str, client_id: str):
"""
Authenticate Software Heritage users with OpenID Connect.
This CLI tool eases the retrieval of bearer tokens to authenticate
This CLI tool eases the retrieval of a bearer token to authenticate
a user querying the Software Heritage Web API.
"""
ctx.ensure_object(dict)
......@@ -65,54 +60,34 @@ def login(ctx: Context, username: str):
Login and create new offline OpenID Connect session.
Login with USERNAME, create a new OpenID Connect session and get
access and refresh tokens.
User will be prompted for his password and tokens will be printed in
JSON format to standard output.
bearer token.
When its access token has expired, user can request a new one using the
session-refresh command of that CLI tool without having to authenticate
using a password again.
User will be prompted for his password and tokens will be printed
to standard output.
The created OpenID Connect session is an offline one so the provided
refresh token has a much longer expiration time than classical OIDC
token has a much longer expiration time than classical OIDC
sessions (usually several dozens of days).
"""
password = getpass()
oidc_profile = ctx.obj["oidc_session"].login(username, password)
_output_json(oidc_profile)
@auth.command("refresh")
@click.argument("refresh_token")
@click.pass_context
def refresh(ctx: Context, refresh_token: str):
"""
Refresh an offline OpenID Connect session.
Get a new access token from REFRESH_TOKEN when previous one expired.
New access token will be printed in JSON format to standard output.
"""
oidc_profile = ctx.obj["oidc_session"].refresh(refresh_token)
if "access_token" in oidc_profile:
_output_json(oidc_profile["access_token"])
oidc_info = ctx.obj["oidc_session"].login(username, password)
if "refresh_token" in oidc_info:
print(oidc_info["refresh_token"])
else:
# print oidc error
_output_json(oidc_profile)
print(oidc_info)
@auth.command("logout")
@click.argument("refresh_token")
@click.argument("token")
@click.pass_context
def logout(ctx: Context, refresh_token: str):
def logout(ctx: Context, token: str):
"""
Logout from an offline OpenID Connect session.
Use REFRESH_TOKEN to logout from an offline OpenID Connect session.
Use TOKEN to logout from an offline OpenID Connect session.
Access and refresh tokens are no more usable after that operation.
The token is definitely revoked after that operation.
"""
ctx.obj["oidc_session"].logout(refresh_token)
ctx.obj["oidc_session"].logout(token)
print("Successfully logged out from OpenID Connect session")
......@@ -28,7 +28,6 @@ conversions and pagination.
"""
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, Generator, List, Optional, Union
from urllib.parse import urlparse
......@@ -39,8 +38,6 @@ from swh.model.identifiers import SNAPSHOT, REVISION, RELEASE, DIRECTORY, CONTEN
from swh.model.identifiers import PersistentId as PID
from swh.model.identifiers import parse_persistent_identifier as parse_pid
from .auth import AuthenticationError, OpenIDConnectSession, SWH_OIDC_SERVER_URL
PIDish = Union[PID, str]
ORIGIN_VISIT = "origin_visit"
......@@ -107,9 +104,9 @@ def typify(data: Any, obj_type: str) -> Any:
elif obj_type == CONTENT:
pass # nothing to do for contents
elif obj_type == ORIGIN_VISIT:
data['date'] = to_date(data['date'])
if data['snapshot'] is not None:
data['snapshot'] = to_pid(SNAPSHOT, data['snapshot'])
data["date"] = to_date(data["date"])
if data["snapshot"] is not None:
data["snapshot"] = to_pid(SNAPSHOT, data["snapshot"])
else:
raise ValueError(f"invalid object type: {obj_type}")
......@@ -125,8 +122,8 @@ class WebAPIClient:
def __init__(
self,
api_url="https://archive.softwareheritage.org/api/1",
auth_url=SWH_OIDC_SERVER_URL,
api_url: str = "https://archive.softwareheritage.org/api/1",
bearer_token: Optional[str] = None,
):
"""Create a client for the Software Heritage Web API
......@@ -135,15 +132,14 @@ class WebAPIClient:
Args:
api_url: base URL for API calls (default:
"https://archive.softwareheritage.org/api/1")
bearer_token: optional bearer token to do authenticated API calls
"""
api_url = api_url.rstrip("/")
u = urlparse(api_url)
self.api_url = api_url
self.api_path = u.path
self.oidc_session = OpenIDConnectSession(oidc_server_url=auth_url)
self.oidc_profile: Optional[Dict[str, Any]] = None
self.bearer_token = bearer_token
self._getters: Dict[str, Callable[[PIDish], Any]] = {
CONTENT: self.content,
......@@ -175,13 +171,8 @@ class WebAPIClient:
r = None
headers = {}
if self.oidc_profile is not None:
# use bearer token authentication
if datetime.now() > self.oidc_profile["expires_at"]:
# refresh access token if it has expired
self.authenticate(self.oidc_profile["refresh_token"])
access_token = self.oidc_profile["access_token"]
headers = {"Authorization": f"Bearer {access_token}"}
if self.bearer_token is not None:
headers = {"Authorization": f"Bearer {self.bearer_token}"}
if http_method == "get":
r = requests.get(url, **req_args, headers=headers)
......@@ -336,11 +327,13 @@ class WebAPIClient:
else:
done = True
def visits(self,
origin: str,
per_page: Optional[int] = None,
last_visit: Optional[int] = None,
**req_args) -> Generator[Dict[str, Any], None, None]:
def visits(
self,
origin: str,
per_page: Optional[int] = None,
last_visit: Optional[int] = None,
**req_args,
) -> Generator[Dict[str, Any], None, None]:
"""List visits of an origin
Args:
......@@ -365,14 +358,14 @@ class WebAPIClient:
if per_page is not None:
params.append(("per_page", per_page))
query = f'origin/{origin}/visits/'
query = f"origin/{origin}/visits/"
while not done:
r = self._call(query, http_method='get', params=params, **req_args)
r = self._call(query, http_method="get", params=params, **req_args)
yield from [typify(v, ORIGIN_VISIT) for v in r.json()]
if 'next' in r.links and 'url' in r.links['next']:
if "next" in r.links and "url" in r.links["next"]:
params = []
query = r.links['next']['url']
query = r.links["next"]["url"]
else:
done = True
......@@ -480,29 +473,3 @@ class WebAPIClient:
r.raise_for_status()
yield from r.iter_content(chunk_size=None, decode_unicode=False)
def authenticate(self, refresh_token: str):
"""Authenticate API requests using OpenID Connect bearer token
Args:
refresh_token: A refresh token retrieved using the
``swh auth login`` command (see :ref:`swh-web-client-auth`
section in main documentation)
Raises:
swh.web.client.auth.AuthenticationError: if authentication fails
"""
now = datetime.now()
try:
self.oidc_profile = self.oidc_session.refresh(refresh_token)
assert self.oidc_profile
if "expires_in" in self.oidc_profile:
expires_in = self.oidc_profile["expires_in"]
expires_at = now + timedelta(seconds=expires_in)
self.oidc_profile["expires_at"] = expires_at
except Exception as e:
raise AuthenticationError(str(e))
if "access_token" not in self.oidc_profile:
# JSON error response
raise AuthenticationError(self.oidc_profile)
......@@ -3,8 +3,6 @@
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
import json
from click.testing import CliRunner
from swh.web.client.cli import auth
......@@ -31,7 +29,7 @@ def test_auth_login(mocker):
result = runner.invoke(auth, ["login", "username"], input="password\n")
assert result.exit_code == 0
assert json.loads(result.output) == oidc_profile
assert result.output[:-1] == oidc_profile["refresh_token"]
mock_login.side_effect = Exception("Auth error")
......@@ -39,21 +37,6 @@ def test_auth_login(mocker):
assert result.exit_code == 1
def test_auth_refresh(mocker):
mock_oidc_session = mocker.patch("swh.web.client.cli.OpenIDConnectSession")
mock_refresh = mock_oidc_session.return_value.refresh
mock_refresh.return_value = oidc_profile
result = runner.invoke(auth, ["refresh", oidc_profile["refresh_token"]])
assert result.exit_code == 0
assert json.loads(result.stdout) == oidc_profile["access_token"]
mock_refresh.side_effect = Exception("Auth error")
result = runner.invoke(auth, ["refresh", oidc_profile["refresh_token"]])
assert result.exit_code == 1
def test_auth_logout(mocker):
mock_oidc_session = mocker.patch("swh.web.client.cli.OpenIDConnectSession")
......
......@@ -3,18 +3,10 @@
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
from copy import copy
from datetime import datetime
from dateutil.parser import parse as parse_date
from unittest.mock import call, Mock
import pytest
from swh.web.client.auth import AuthenticationError
from swh.model.identifiers import parse_persistent_identifier as parse_pid
from .test_cli import oidc_profile
def test_get_content(web_api_client, web_api_mock):
pid = parse_pid("swh:1:cnt:fe95a46679d128ff167b7c55df5d02356c5a1ae1")
......@@ -118,105 +110,36 @@ def test_iter_snapshot(web_api_client, web_api_mock):
assert len(snp) == 1391
def test_authenticate_success(web_api_client, web_api_mock):
rel_id = "b9db10d00835e9a43e2eebef2db1d04d4ae82342"
url = f"{web_api_client.api_url}/release/{rel_id}/"
web_api_client.oidc_session = Mock()
web_api_client.oidc_session.refresh.return_value = copy(oidc_profile)
access_token = oidc_profile["access_token"]
refresh_token = "user-refresh-token"
web_api_client.authenticate(refresh_token)
assert "expires_at" in web_api_client.oidc_profile
pid = parse_pid(f"swh:1:rel:{rel_id}")
web_api_client.get(pid)
web_api_client.oidc_session.refresh.assert_called_once_with(refresh_token)
sent_request = web_api_mock._adapter.last_request
assert sent_request.url == url
assert "Authorization" in sent_request.headers
assert sent_request.headers["Authorization"] == f"Bearer {access_token}"
def test_authenticate_refresh_token(web_api_client, web_api_mock):
def test_authentication(web_api_client, web_api_mock):
rel_id = "b9db10d00835e9a43e2eebef2db1d04d4ae82342"
url = f"{web_api_client.api_url}/release/{rel_id}/"
oidc_profile_cp = copy(oidc_profile)
web_api_client.oidc_session = Mock()
web_api_client.oidc_session.refresh.return_value = oidc_profile_cp
refresh_token = "user-refresh-token"
web_api_client.authenticate(refresh_token)
assert "expires_at" in web_api_client.oidc_profile
# simulate access token expiration
web_api_client.oidc_profile["expires_at"] = datetime.now()
access_token = "new-access-token"
oidc_profile_cp["access_token"] = access_token
web_api_client.bearer_token = refresh_token
pid = parse_pid(f"swh:1:rel:{rel_id}")
web_api_client.get(pid)
calls = [call(refresh_token), call(oidc_profile["refresh_token"])]
web_api_client.oidc_session.refresh.assert_has_calls(calls)
sent_request = web_api_mock._adapter.last_request
assert sent_request.url == url
assert "Authorization" in sent_request.headers
assert sent_request.headers["Authorization"] == f"Bearer {access_token}"
def test_authenticate_failure(web_api_client, web_api_mock):
msg = "Authentication error"
web_api_client.oidc_session = Mock()
web_api_client.oidc_session.refresh.side_effect = Exception(msg)
refresh_token = "user-refresh-token"
with pytest.raises(AuthenticationError) as e:
web_api_client.authenticate(refresh_token)
assert e.match(msg)
oidc_error_response = {
"error": "invalid_grant",
"error_description": "Invalid refresh token",
}
web_api_client.oidc_session.refresh.side_effect = None
web_api_client.oidc_session.refresh.return_value = oidc_error_response
with pytest.raises(AuthenticationError) as e:
web_api_client.authenticate(refresh_token)
assert e.match(repr(oidc_error_response))
assert sent_request.headers["Authorization"] == f"Bearer {refresh_token}"
def test_get_visits(web_api_client, web_api_mock):
obj = web_api_client.visits('https://github.com/NixOS/nixpkgs',
last_visit=50,
per_page=10)
obj = web_api_client.visits(
"https://github.com/NixOS/nixpkgs", last_visit=50, per_page=10
)
visits = [v for v in obj]
assert len(visits) == 20
timestamp = parse_date('2018-07-31 04:34:23.298931+00:00')
assert visits[0]['date'] == timestamp
timestamp = parse_date("2018-07-31 04:34:23.298931+00:00")
assert visits[0]["date"] == timestamp
assert visits[0]["snapshot"] is None
snapshot_pid = 'swh:1:snp:456550ea74af4e2eecaa406629efaaf0b9b5f976'
snapshot_pid = "swh:1:snp:456550ea74af4e2eecaa406629efaaf0b9b5f976"
assert visits[7]["snapshot"] == parse_pid(snapshot_pid)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment