Skip to content

Commit

Permalink
Merge pull request #18 from NatLibFi/passkey-endpoints
Browse files Browse the repository at this point in the history
SIMPLYE-218 Passkey endpoints
  • Loading branch information
jompu authored Feb 27, 2024
2 parents 3f7a6db + 2400816 commit 2de18b4
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 14 deletions.
56 changes: 49 additions & 7 deletions api/ekirjasto_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
from api.config import Configuration
from api.problem_details import (
EKIRJASTO_REMOTE_AUTHENTICATION_FAILED,
EKIRJASTO_REMOTE_ENDPOINT_FAILED,
EKIRJASTO_REMOTE_METHOD_NOT_SUPPORTED,
INVALID_EKIRJASTO_DELEGATE_TOKEN,
INVALID_EKIRJASTO_TOKEN,
UNSUPPORTED_AUTHENTICATION_MECHANISM,
Expand Down Expand Up @@ -182,7 +184,10 @@ def _authentication_flow_document(self, _db: Session) -> dict[str, Any]:
"type": self.flow_type,
"description": self.label(),
"links": [
{"rel": "authenticate", "href": self._create_authenticate_url(_db)},
{
"rel": "authenticate",
"href": self._create_circulation_url("ekirjasto_authenticate", _db),
},
{"rel": "api", "href": self._ekirjasto_api_url},
{
"rel": "tunnistus_start",
Expand All @@ -202,18 +207,22 @@ def _authentication_flow_document(self, _db: Session) -> dict[str, Any]:
},
{
"rel": "passkey_register_start",
"href": f"{self._ekirjasto_api_url}/v1/auth/passkey/register/start",
"href": self._create_circulation_url(
"ekirjasto_passkey_register_start", _db
),
},
{
"rel": "passkey_register_finish",
"href": f"{self._ekirjasto_api_url}/v1/auth/passkey/register/finish",
"href": self._create_circulation_url(
"ekirjasto_passkey_register_finish", _db
),
},
],
}

return flow_doc

def _create_authenticate_url(self, db):
def _create_circulation_url(self, endpoint, db):
"""Returns an authentication link used by clients to authenticate patrons
:param db: Database session
Expand All @@ -226,7 +235,7 @@ def _create_authenticate_url(self, db):
library = self.library(db)

return url_for(
"ekirjasto_authenticate",
endpoint,
_external=True,
library_short_name=library.short_name,
provider=self.label(),
Expand Down Expand Up @@ -551,6 +560,39 @@ def remote_authenticate(

return self.remote_patron_lookup(ekirjasto_token)

def remote_endpoint(
self, remote_path: str, token: str, method: str, json_body: object = None
) -> tuple[ProblemDetail, None] | tuple[object, int]:
"""Call E-kirjasto API's passkey register endpoints on behalf of the user.
:return: token and expire timestamp if refresh was succesfull or None | ProblemDetail otherwise.
"""

url = self._ekirjasto_api_url + remote_path

try:
if method == "POST":
response = self.requests_post(url, token, json_body)
elif method == "GET":
response = self.requests_get(url, token)
else:
return EKIRJASTO_REMOTE_METHOD_NOT_SUPPORTED, None
except requests.exceptions.ConnectionError as e:
raise RemoteInitiatedServerError(str(e), self.__class__.__name__)

if response.status_code == 401:
# Do nothing if authentication fails, e.g. token expired.
return INVALID_EKIRJASTO_TOKEN, None
elif response.status_code != 200:
return EKIRJASTO_REMOTE_ENDPOINT_FAILED, None

try:
response_json = response.json()
except requests.exceptions.JSONDecodeError as e:
response_json = None

return response_json, response.status_code

def authenticate_and_update_patron(
self, _db: Session, ekirjasto_token: str | None
) -> Patron | PatronData | ProblemDetail | None:
Expand Down Expand Up @@ -715,8 +757,8 @@ def requests_get(self, url, ekirjasto_token=None):
headers = {"Authorization": f"Bearer {ekirjasto_token}"}
return requests.get(url, headers=headers)

def requests_post(self, url, ekirjasto_token=None):
def requests_post(self, url, ekirjasto_token=None, json_body=None):
headers = None
if ekirjasto_token:
headers = {"Authorization": f"Bearer {ekirjasto_token}"}
return requests.post(url, headers=headers)
return requests.post(url, headers=headers, json=json_body)
44 changes: 40 additions & 4 deletions api/ekirjasto_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,15 @@ def get_tokens(self, authorization, validate_expire=False):
if self.is_configured != True:
return EKIRJASTO_PROVIDER_NOT_CONFIGURED, None, None, None

token = authorization.token
if token is None or len(token) == 0:
if (
authorization is None
or authorization.token is None
or len(authorization.token) == 0
):
return EKIRJASTO_REMOTE_AUTHENTICATION_FAILED, None, None, None

token = authorization.token

ekirjasto_token = None
delegate_token = None
delegate_sub = None
Expand All @@ -79,8 +84,8 @@ def get_tokens(self, authorization, validate_expire=False):
token, validate_expire=validate_expire, decrypt_ekirjasto_token=True
)
if isinstance(delegate_payload, ProblemDetail):
# The ekirjasto_token might be ProblemDetail, indicating that the token
# is not valid. Still it might be ekirjasto_token (which is not JWT or
# The delegate_payload might be ProblemDetail, indicating that the token
# is not valid. Still thetoken might be ekirjasto_token (which is not JWT or
# at least not signed by us), so we can continue.
ekirjasto_token = token
else:
Expand Down Expand Up @@ -194,3 +199,34 @@ def authenticate(self, request, _db):
)
response_code = 201 if is_patron_new else 200
return Response(response_json, response_code, mimetype="application/json")

def call_remote_endpoint(self, remote_path, request, _db):
"""Call E-kirjasto API's passkey register endpoints on behalf of the user."""
if self.is_configured != True:
return EKIRJASTO_PROVIDER_NOT_CONFIGURED

(
delegate_token,
ekirjasto_token,
delegate_sub,
delegate_expired,
) = self.get_tokens(request.authorization)
if isinstance(delegate_token, ProblemDetail):
return delegate_token
elif delegate_token == None:
return INVALID_EKIRJASTO_DELEGATE_TOKEN

(
response_json,
response_code,
) = self._authenticator.ekirjasto_provider.remote_endpoint(
remote_path, ekirjasto_token, request.method, request.json
)
if isinstance(response_json, ProblemDetail):
return response_json
elif isinstance(response_json, dict):
response_json = json.dumps(response_json)
else:
response_json = None

return Response(response_json, response_code, mimetype="application/json")
16 changes: 16 additions & 0 deletions api/problem_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,14 @@
detail=_("Ekirjasto provider was not configured for the library"),
)

# Finland
EKIRJASTO_REMOTE_METHOD_NOT_SUPPORTED = pd(
"http://librarysimplified.org/terms/problem/requested-provider-not-configured",
status_code=415,
title=_("Ekirjasto remote method not supported."),
detail=_("Method for a remote call not supported."),
)

INVALID_SAML_BEARER_TOKEN = pd(
"http://librarysimplified.org/terms/problem/credentials-invalid",
status_code=401,
Expand Down Expand Up @@ -291,6 +299,14 @@
detail=_("Authentication with ekirjasto API failed, for unknown reason."),
)

# Finland
EKIRJASTO_REMOTE_ENDPOINT_FAILED = pd(
"http://librarysimplified.org/terms/problem/credentials-invalid",
status_code=400,
title=_("Call to ekirjasto API failed."),
detail=_("Call to ekirjasto API failed, for unknown reason."),
)

UNSUPPORTED_AUTHENTICATION_MECHANISM = pd(
"http://librarysimplified.org/terms/problem/unsupported-authentication-mechanism",
status_code=400,
Expand Down
22 changes: 22 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,28 @@ def ekirjasto_authenticate():
return app.manager.ekirjasto_controller.authenticate(request, app.manager._db)


# Finland
# Authenticate with the ekirjasto token.
@library_route("/ekirjasto/passkey/register/start", methods=["POST"])
@has_library
@returns_problem_detail
def ekirjasto_passkey_register_start():
return app.manager.ekirjasto_controller.call_remote_endpoint(
"/v1/auth/passkey/register/start", request, app.manager._db
)


# Finland
# Authenticate with the ekirjasto token.
@library_route("/ekirjasto/passkey/register/finish", methods=["POST"])
@has_library
@returns_problem_detail
def ekirjasto_passkey_register_finish():
return app.manager.ekirjasto_controller.call_remote_endpoint(
"/v1/auth/passkey/register/finish", request, app.manager._db
)


# Finland
# Get descriptions for the library catalogs in the system.
# This is public route.
Expand Down
82 changes: 79 additions & 3 deletions tests/api/finland/test_ekirjasto.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,11 @@ def __init__(

self.mock_api = MockEkirjastoRemoteAPI()

def _create_authenticate_url(self, db):
def _create_circulation_url(self, endpoint, db):
library = self.library(db)

return url_for(
"ekirjasto_authenticate",
endpoint,
_external=True,
library_short_name="test-library",
provider=self.label(),
Expand All @@ -178,7 +180,7 @@ def requests_get(self, url, ekirjasto_token=None):

assert None, f"Mockup for GET {url} not created"

def requests_post(self, url, ekirjasto_token=None):
def requests_post(self, url, ekirjasto_token=None, json_body=None):
if self.bad_connection:
raise requests.exceptions.ConnectionError(
"Connection error", self.__class__.__name__
Expand Down Expand Up @@ -247,6 +249,18 @@ def test_authentication_flow_document(
== "http://localhost/test-library/ekirjasto_authenticate?provider=E-kirjasto+provider+for+circulation+manager"
)

assert (
doc["links"][6]["rel"] == "passkey_register_start"
and doc["links"][6]["href"]
== "http://localhost/test-library/ekirjasto/passkey/register/start?provider=E-kirjasto+provider+for+circulation+manager"
)

assert (
doc["links"][7]["rel"] == "passkey_register_finish"
and doc["links"][7]["href"]
== "http://localhost/test-library/ekirjasto/passkey/register/finish?provider=E-kirjasto+provider+for+circulation+manager"
)

def test_from_config(
self,
create_settings: Callable[..., EkirjastoAuthAPISettings],
Expand Down Expand Up @@ -771,3 +785,65 @@ def test_authenticated_patron_ekirjasto_token_invalid(
)
assert isinstance(patron, Patron)
assert PatronUtility.needs_external_sync(patron) == False

def test_remote_endpoint_get_success(
self,
create_provider: Callable[..., MockEkirjastoAuthenticationAPI],
):
provider = create_provider()
user_id = "verified"
token, expires = provider.mock_api.get_test_access_token_for_user(user_id)

response_json, response_code = provider.remote_endpoint(
"/v1/auth/userinfo", token, "GET"
)

assert isinstance(response_json, dict)
assert response_code == 200

def test_remote_endpoint_post_success(
self,
create_provider: Callable[..., MockEkirjastoAuthenticationAPI],
):
provider = create_provider()
user_id = "verified"
token, expires = provider.mock_api.get_test_access_token_for_user(user_id)

response_json, response_code = provider.remote_endpoint(
"/v1/auth/refresh", token, "POST", {"empty": "json"}
)

assert isinstance(response_json, dict)
assert response_code == 200

def test_remote_endpoint_invalid_token(
self,
create_provider: Callable[..., MockEkirjastoAuthenticationAPI],
):
provider = create_provider()
user_id = "verified"
token, expires = provider.mock_api.get_test_access_token_for_user(user_id)

# Invalidate the token.
provider.mock_api._refresh_token_for_user_id(user_id)

response_json, response_code = provider.remote_endpoint(
"/v1/auth/userinfo", token, "GET"
)

assert isinstance(response_json, ProblemDetail)
assert response_json.status_code == 401

def test_remote_endpoint_unsupported_method(
self,
create_provider: Callable[..., MockEkirjastoAuthenticationAPI],
):
provider = create_provider()

response_json, response_code = provider.remote_endpoint(
"/v1/auth/userinfo", "token", "PUT"
)

assert isinstance(response_json, ProblemDetail)
assert response_json.status_code == 415
assert response_code == None

0 comments on commit 2de18b4

Please sign in to comment.